Checking error handling on the crop sheet surfaced three real problems, two of
which I had introduced.
1. Crash on a high-megapixel photo. The picked image was decoded at full size
and handed to a hardware Canvas. An 8000x8000 photo decodes to 256MB, which
Android allocates but the canvas refuses to draw ("trying to draw too large
bitmap") — thrown inside Compose's draw phase, where the runCatching around
the decode can't see it. Proven live: an 8000x8000 pick killed the app.
Fixed by subsampling at decode (inSampleSize from a bounds-only pass) so the
working bitmap is capped at ~2048px / ~16MB. Native heap on that pick went
256MB(attempted) -> 46MB.
2. The subsample fix then broke decoding for EVERY image. decodeStream returns
null by design in inJustDecodeBounds mode, and I had `?: return null` on it —
so loadOriented bailed right after the bounds pass, for all inputs. It only
looked like "the huge photo failed"; a normal photo would have failed too. I
never re-tested a small image after the change. The new AvatarCropSheetTest
caught it. The stream is now what's guarded; the real check is the header size.
3. Errors were swallowed and off-standard. runCatching{}.getOrNull() dropped the
cause and a failure silently dismissed the sheet — indistinguishable from a
save that did nothing. Now unified with the screen's own pattern
(EditProfileViewModel.save): the sheet reports the Throwable up via a new
onError, the VM records it through the injected CrashReporter and surfaces
the message through uiState.error -> the existing snackbar. The crop also
moved off the main thread (withContext(IO)); inline, `saving` flipped within
one frame and never showed.
Adds AvatarCropSheetTest (androidTest, 4 tests, on-device BitmapFactory/Canvas):
subsample math for 8000+/oversized, oversized decode stays bounded, garbage
returns null instead of throwing, crop output is square and bounded. All green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>