Skip to content

fix: keep photo quality for storage, reduce only for AI/API requests - #58

Merged
jvsena42 merged 7 commits into
mainfrom
fix/photo-quality-storage
Aug 8, 2026
Merged

fix: keep photo quality for storage, reduce only for AI/API requests#58
jvsena42 merged 7 commits into
mainfrom
fix/photo-quality-storage

Conversation

@jvsena42

@jvsena42 jvsena42 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #57.

The photo pipeline was degrading quality in exactly the wrong places: storage lost resolution it should have kept, while outbound API requests carried the full, unbounded bytes.

Storage was losing quality

  • The crop editor decoded a ~2048px preview and cropped that, so cropping a 12MP photo permanently threw away most of its resolution — on both platforms.
  • Android camera capture used ActivityResultContracts.TakePicturePreview, which returns the camera app's thumbnail extra (a few hundred pixels), not the real frame.

AI/API requests had no reduction at all

  • ClaudeApiClient base64-encoded the stored bytes verbatim. Base64 inflates ~33%, so a phone photo can exceed Anthropic's 5MB per-image limit and surface as a bare 400 — while anything above ~1.15MP is downscaled server-side anyway, so the extra pixels only bought image tokens.
  • media_type was hardcoded image/jpeg while gallery and share picks passed through raw, so an iOS HEIC pick was sent mislabelled.

Photos are now stored at capture quality, and a single shared downscale step runs on the way out to any AI/API call. Re-encoding at that step also makes the declared image/jpeg honest.

Changes

  • New shared ImageDownscaler (expect/actual) in data/source/image/, a sibling of BackgroundRemover. The sizing math lives in commonMain (ImageScaling) so Android and iOS cannot drift, and so it is testable without Robolectric. Both actuals decode through an API that applies EXIF orientation — BitmapFactory ignores it, so re-encoding through that path would have dropped the tag and handed the model a sideways photo.
  • Wired into the repositories, not ClaudeApiClient (a thin HTTP wrapper) or the UI: 1568px for Claude analysis, 2048px for YouCam try-on, which generates an image and so loses visibly more to a small input.
  • Crop preserves capture resolution on both platforms. The ~2048px decode stays as the on-screen preview (it exists so a 12MP bitmap is not held resident through a drag), but the crop re-reads the original and maps the selection against the full-size bitmap's own dimensions — which also removes the class of bug where a rect is measured against one image and applied to another.
  • Android camera captures the real frame: TakePicture writing into a FileProvider URI over a cache scratch file. The bytes are stored as-is, EXIF and all — no re-encode, which makes the two JPEG_QUALITY = 90 constants that recompressed the thumbnail redundant.
  • Stored re-encodes go 90 → 95 (crop, background removal, iOS camera). iOS camera encoding is hoisted into PhotoEncoding instead of three drifting inline 0.9 literals.
  • Read side, since full-res photos make it matter: Android previews route through the downsampling decodePreviewImage instead of main-thread full-resolution BitmapFactory calls; iOS gets StoredPhotoImage, which decodes via CGImageSourceCreateThumbnailAtIndex at the laid-out size. That also drops the per-cell FileManager.fileExists blocking stat on the main thread — something Android's ClothingPhoto already avoids deliberately.
  • androidx.core:core:1.19.0 declared explicitly rather than leaned on transitively, since FileProvider now comes from it. (core-ktx is an empty artifact now — the Kotlin extensions merged into core.)

Test plan

Verified on an emulator (API 36, sdk_gphone64_x86_64), with the test items and pushed fixtures cleaned up afterwards.

Camera full resolution

dimensions size
pre-change item (TakePicturePreview) 180×240 4.3 KB
new capture (TakePicture + FileProvider) 1440×1920 63.9 KB

8× linear, 64× the pixels. The camera launched, wrote to our URI, and returned with no SecurityException or IllegalArgumentException.

EXIF orientation — needed a dedicated fixture, because the emulator camera writes orientation=1 and so never exercises the rotation path. Fed a JPEG stored sideways on disk (1200×900) tagged orientation=6, with unmistakable landmarks (red band = top, blue = bottom, triangle pointing up):

  • system photo picker (ground truth): upright
  • app preview after picking: triangle pointing up
  • crop editor, which fits the whole frame: red top, blue bottom, triangle up, portrait — matching the intended rendering exactly

The old BitmapFactory path would have shown that landscape with the red band down the left edge.

Crop geometry with EXIF in play — selected the top-left quadrant; the saved result was exactly that region (red band on top, black left border, the triangle's left edge in the bottom-right).

Crop preserves resolution — picked a 4608×3456 (16MP) image and cropped the full frame: stored 4608×3456. The old power-of-two subsample would have stored 2304×1728.

No crashes, no OOM, no exceptions in logcat across the 16MP decode → crop → save → grid render.

Automated./gradlew :shared:allTests (172 Android host tests, 0 failures, including 9 new ImageScalingTest cases), :composeApp:assembleDebug, :shared:compileKotlinIosSimulatorArm64, ./gradlew detekt.

Not verified — needs a reviewer's machine

  • ImageDownscaler at runtime. No Claude API key was configured, so Try It sits behind the "Connect Claude" CTA and analyzeProspectiveItem is never reached (analyzeAndTag is unreachable from the UI regardless). Its sizing math is unit-tested, but the ImageDecoder/Bitmap glue in the Android actual has not executed. Worth a pass with a key set: pick a large photo in Try It and confirm the request succeeds where an oversized HEIC/PNG previously 400'd.
  • All iOS changes. They have never been through a compiler — the work was done on Linux, so there is no Xcode. StoredPhotoImage is the riskiest piece: a new GeometryReader + task(id:) view used at five call sites. Please build iosApp/ and check a wardrobe grid renders and scrolls, plus the crop and camera flows.

Expected consequence — photos already stored stay as they are. Anything captured before this change remains a thumbnail; only new captures benefit.

Checklist

  • ./gradlew detekt passes
  • Tested on Android
  • Tested on iOS
  • Updated documentation (if applicable)

🤖 Generated with Claude Code

jvsena42 and others added 7 commits August 8, 2026 08:00
Photos are stored at capture quality, but sending those bytes verbatim to
Claude is wasteful and past a point broken: base64 inflates a request by
~33%, Anthropic rejects images over 5MB, and it downscales anything above
~1.15MP internally anyway. Re-encoding to JPEG here also makes the
hardcoded image/jpeg media type honest for HEIC and PNG gallery picks.

The sizing math lives in commonMain so Android and iOS cannot drift, and
so it is testable without Robolectric. Both actuals decode through an API
that applies EXIF orientation, since re-encoding without it would drop
the tag and hand the model a sideways photo.

Nothing calls this yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Requests now carry a bounded copy of the photo instead of the stored
bytes: 1568px for Claude analysis, 2048px for YouCam try-on, which
generates an image and so loses visibly more to a small input. Storage
is untouched.

Downscaling belongs here rather than in ClaudeApiClient, which is a thin
HTTP wrapper, or in the UI, which would have to remember to do it at
every call site.

Also normalises HEIC and PNG picks to real JPEG, which both clients
already declared but never enforced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The editor decoded a ~2048px preview and cropped that, so cropping a
12MP photo permanently threw away most of its resolution. cropToJpeg now
re-reads the original bytes and maps the selection against the full-size
bitmap's own dimensions, which also removes any chance of a rect
measured against one image being applied to another.

Both decodes move to ImageDecoder, which applies EXIF orientation.
BitmapFactory ignores it, so a camera photo showed sideways in the
editor while the grid — which goes through Coil — showed it upright, and
the crop landed on the wrong axis.

Stored JPEG quality goes 90 -> 95; the reduction now happens on the
request path instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TakePicturePreview returns the camera app's thumbnail extra — a few
hundred pixels — so every photo taken in-app was stored at a fraction of
the sensor's resolution. TakePicture writes the real frame into a URI we
supply instead, which needs a FileProvider over a cache scratch file.

The captured bytes are stored as-is, EXIF and all: no re-encode, so
nothing is lost between the sensor and disk. That makes the two
JPEG_QUALITY constants that re-compressed the thumbnail redundant.

Photos are now large enough that decoding one for a preview matters, so
the remaining main-thread full-resolution BitmapFactory decodes in
AddItemSheet move to the shared, downsampling decodePreviewImage.

androidx.core is declared explicitly rather than leaned on transitively,
since FileProvider now comes from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
prepareForEditing downsampled to 2048px and the crop ran on that, so
cropping a 12MP photo permanently threw away most of its resolution. The
downsample is now preview-only: crop re-reads the original data and maps
the selection against the full-size buffer's own dimensions.

Camera captures — the one place the app encodes a stored photo itself,
since UIImagePickerController hands back a UIImage rather than file data
— go through PhotoEncoding at 0.95 instead of three drifting inline 0.9
literals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Photos now keep their capture resolution, which makes the read side
matter. AsyncImage decodes the full bitmap regardless of the frame it
renders into, so a wardrobe grid would decode ~48MB per visible cell.
StoredPhotoImage goes through CGImageSourceCreateThumbnailAtIndex at the
laid-out size instead, and honours the EXIF transform while it is there.

It also drops the per-cell FileManager.fileExists check, which was a
blocking stat on the main thread for every visible card — Android's
ClothingPhoto already avoids that deliberately. A failed load falls back
to the same placeholder the check was guarding.

Background removal preserves resolution, so its re-encode is a storage
one: 90 -> 95 on both platforms, matching the crop path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An array-of-one worked but read as a puzzle. Nothing renders the URI, so
a change should not recompose — which is why it is not mutableStateOf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42
jvsena42 enabled auto-merge August 8, 2026 11:35
@jvsena42
jvsena42 merged commit 6f6f1a2 into main Aug 8, 2026
2 checks passed
@jvsena42
jvsena42 deleted the fix/photo-quality-storage branch August 8, 2026 11:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keep the photo quality for storage

1 participant