Skip to content

fix(mobile): apply exif orientation to android raw photos#29337

Open
santoshakil wants to merge 2 commits into
mainfrom
fix/android-raw-orientation
Open

fix(mobile): apply exif orientation to android raw photos#29337
santoshakil wants to merge 2 commits into
mainfrom
fix/android-raw-orientation

Conversation

@santoshakil

@santoshakil santoshakil commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Description

raw photos (DNG etc) shot in portrait showed up sideways on android, both in the timeline grid and the full viewer. jpeg and heic were always fine.

turns out android's ImageDecoder and loadThumbnail (API 29+) don't apply the EXIF orientation tag for raw files, so the decoded bitmap comes back unrotated. the jpeg/heic decoders rotate on their own, raw doesn't.

so for raw we read the orientation tag and rotate the pixels ourselves. instead of allocating a second rotated bitmap (skia's createBitmap), we lock the decoded bitmap and rotate straight into the output buffer we already hand back to dart, in one native pass in C (added to the existing native_buffer lib, which already links jnigraphics). no extra full bitmap, and it's about 6x faster than the createBitmap approach on big raws. it runs on the background decode pool so it doesn't block the ui. the "load original" full-res decode is capped to ~24mp so memory stays bounded on big-sensor phones, which only trims pixels on huge raws (they still come out upright). non-8888 decodes (e.g. hdr dng) get converted to 8888 and rotated natively too, with the skia path kept only as a safety net if the native rotate ever fails (e.g. oom). jpeg/heic and the pre-29 paths are untouched. on-device (local) raw only.

fixes #24796

tested on a pixel 9a with real DNGs: orientation 6 (portrait) and 8 (front-cam) were sideways before, now upright in the grid, the viewer, and on load-original, matching the paired jpeg and byte-for-byte identical to the old skia rotate. all 8 EXIF orientations verified against the spec (cameras only emit 1/3/6/8 in practice). no jank, no native crash, no leak in logcat; jpeg/heic still display correctly.

android's ImageDecoder/loadThumbnail (API 29+) skip the EXIF orientation
tag for raw files like DNG, so portrait raw shots showed up sideways in
the grid and viewer. jpeg/heic were fine since those decoders rotate on
their own.

read the orientation tag and rotate the decoded raw bitmap to match, on
the same background pool so the ui doesn't jank. the load-original
full-res decode is sampled down first so the rotate copy can't OOM on
high-mp sensors. raw only, jpeg/heic and pre-29 paths unchanged.
@immich-push-o-matic

immich-push-o-matic Bot commented Jun 26, 2026

Copy link
Copy Markdown

📱 Android release APK (universal)2c9b43b92533cbd4a007fd395d08c6952cbf5bbb

Download: https://github.com/immich-app/immich/actions/runs/28241443757/artifacts/7906911071

QR code QR code

Installs as a separate app (applicationId app.alextran.immich.pr29337), so it coexists with the Play Store version and any other PR builds.

@shenlong-tanwen shenlong-tanwen requested a review from mertalev June 26, 2026 09:49
@mertalev

Copy link
Copy Markdown
Member

Surely this can be done in-place rather than allocating a separate bitmap? That seems hugely wasteful.

@santoshakil

Copy link
Copy Markdown
Collaborator Author

Surely this can be done in-place rather than allocating a separate bitmap? That seems hugely wasteful.

fair point. for thumbs and the preview the bitmap is small so it's cheap, and load original is capped at around 24mp anyway, so the double alloc only really hits big raws. but yeah, we copy into a native buffer right after so the separate bitmap is redundant. i can fold the rotate into that copy and skip the second bitmap. only thing, the 90 and 270 case is a transpose and doing it on the jvm might be slower than the native createBitmap, so let me bench both and let's see which one wins.

@santoshakil

Copy link
Copy Markdown
Collaborator Author

@mertalev i looked into this and memory is the same both ways. the source is a skia bitmap but dart needs the pixels in our own buffer, so we hold both at the same time while copying = about 2x the bitmap no matter what. the rotated bitmap from createBitmap only lives for a moment and doesnt push the peak any higher than in place does. so in place doesnt save memory, it just swaps the native rotate for a jvm pixel loop thats about 2x slower on big raws (pixel 9a, 24mp: ~315ms vs ~625ms). only way to go lower is a zero copy bitmap handoff or stripe decoding the raw, and region decode doesnt do dng. so i think, keeping createBitmap is fine unless im missing something. but let me do some more research on if I can do any zero copy pointer reuse from skia bitmap.

the exif rotate now happens in a small native pass (lock pixels + a
tiled copy straight into the output buffer) so there's no second full
bitmap. about 6x faster on big raws. also fixes orientation 5/7 which
were swapped, and forces argb_8888 so high-bit-depth dng don't
under-allocate. keeps the skia path as a fallback for odd formats.
@santoshakil

Copy link
Copy Markdown
Collaborator Author

@mertalev the zero copy route worked out. went native instead of the jvm loop. lock the skia bitmap pixels and rotate straight into our output buffer in c (added it to native_buffer since it already links jnigraphics), so no second bitmap like you wanted. and it ends up ~6x faster than createBitmap on raws, not slower (pixel 9a 24mp: ~330ms vs ~55ms). byte identical output, keeps the skia path as a fallback for odd formats. pushed it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Immich Android RAW Photos Sideways (Pixel 10 Series)

2 participants