ionic-team / ionic-team/capacitor-camera
Android takePhoto(): seconds-long main-thread freeze after camera closes; quality/targetWidth silently ignored for the returned file
- Dominant language
- TypeScript
- Stars
- 4
- Forks
- 1
- Avg merge
- 2d 1h
- Merged PRs (30d)
- 2
Description
## Plugin version
`@capacitor/camera` 8.2.0 (with bundled `io.ionic.libs:ioncamera-android:1.0.1`), Capacitor 8
## Platform
Android (all devices tested, including fast ones). iOS is unaffected (separate implementation).
## Current behavior
Calling `Camera.takePhoto({ quality: 60, targetWidth: 2500, targetHeight: 2500, saveToGallery: true, includeMetadata: true })`:
1. After the camera activity closes, the app freezes (UI + WebView) for **several seconds** before the `takePhoto()` promise resolves.
2. The resolved `uri`/`webPath` point at the **unprocessed full-resolution capture file** — `quality` and `targetWidth`/`targetHeight` are never applied to the file on disk, so apps that upload the file get full camera resolution (5–12 MB instead of ~1 MB).
## Root cause (from reading the sources)
The entire post-capture pipeline runs **synchronously on the main thread**, inside the `ActivityResultLauncher` callback. `IonCameraFlow.handleCameraResult` → `processResult()` calls `cameraManager.processResultFromCamera(...)` directly — unlike `processResultFromVideo`/`processResultFromGallery`, which are wrapped in `CoroutineScope(Dispatchers.Default).launch { ... }`.
On that main thread, for a single capture, the pipeline does:
1. `IONCAMRMediaProcessor.getScaledAndRotatedBitmap`: copies the entire capture file to a second temp file (`writeUncompressedImage`), reads EXIF, decodes bounds, then decodes the bitmap (sampled), scales to target size, rotates via matrix.
2. `imageHelper.compressBitmap(downsizedImage, 100)`: JPEG-encodes the bitmap at **quality 100** into a byte array and immediately `BitmapFactory.decodeByteArray`s it back into a new bitmap. This round-trip has no observable effect except CPU time and allocations.
3. `imageHelper.bitmapToBase64(...)`: downsizes again, JPEG-encodes **again** (at the requested quality), and Base64-encodes the full ~2500px image into a string. This becomes `MediaResult.thumbnail` — megabytes of base64 that are then JSON-serialized through the bridge into the WebView on every capture, whether or not the app uses it.
4. `savePictureInGallery` (when `saveToGallery: true`): full-size copy into MediaStore, also on the main thread.
5. Back in `IonCameraFlow.handleMediaResult`: `BitmapFactory.decodeFile(mediaResult.uri)` decodes the **full-resolution original a second time** (a 12–50 MP ARGB_8888 allocation, i.e. 50–200 MB) solely to pass a bitmap into `ImageUtils.getExifData(context, bitmap, uri)`.
Meanwhile the scaled/compressed bitmap from steps 1–2 is **never written back to disk**: `IONCAMRMediaResult.uri` is the original `imageFilePath`, so the resize/quality options only affect the (typically unused) base64 `thumbnail`.
The combined effect is a multi-second ANR-grade main-thread stall plus a large transient memory spike (multiple full bitmaps + a megabyte base64 string alive at once — on our app this memory spike also contributed to WebView OOM kills when a WebGL map was alive behind the camera).
The legacy `getPhoto()` flow (`LegacyCameraFlow`) does not have these problems to the same degree: one decode, one resize, one encode at the requested quality, the processed file is written to disk with EXIF copied, and with `CameraResultType.Uri` nothing large crosses the bridge. Reverting our app from `takePhoto()` to `getPhoto()` reduced camera-close-to-UI latency from many seconds to near-instant, and restored the intended output file size.
## Expected behavior
- `takePhoto()` post-processing should run on a background dispatcher (same as the video/gallery paths).
- `quality`/`targetWidth`/`targetHeight` should be applied to the file that `uri`/`webPath` point at (or the docs should state they only affect `thumbnail`).
- The base64 `thumbnail` should be opt-in, or at least skipped when the caller doesn't need it — it is by far the largest bridge payload.
- EXIF extraction should not require a second full-resolution bitmap decode (`ExifInterface` works on the file/stream directly; the bitmap parameter is only used for dimensions, which are available from `Options.inJustDecodeBounds`).
- The `compressBitmap(bitmap, 100)` encode/decode round-trip in `createImageMediaResult` appears to be dead work and could be removed.
## Steps to reproduce
1. Fresh Capacitor 8 Android app with `@capacitor/camera` 8.2.0.
2. `Camera.takePhoto({ quality: 60, targetWidth: 2500, targetHeight: 2500, saveToGallery: true, includeMetadata: true })` on a device with a high-resolution camera (tested 12–50 MP).
3. Observe: several seconds of frozen UI between the camera activity closing and the promise resolving (visible as main-thread work in the profiler), and `webPath` serving the full-resolution original.
## Other information
Since `getPhoto()` is deprecated in favor of `takePhoto()`, apps following the migration guide currently regress on Android in both latency and output file size. We're staying on `getPhoto()` until this is addressed.
Contributor guide
Research direction
Start at IonCameraFlow.handleCameraResult and trace processResult() into cameraManager.processResultFromCamera(...), comparing the coroutine-wrapped video and gallery paths. Inspect IONCAMRMediaProcessor, createImageMediaResult, savePictureInGallery, and ImageUtils.getExifData; reproduce with the listed takePhoto options and verify background processing, processed-file dimensions and quality, and reduced bridge work without the freeze.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, kotlin, typescript
- Domain
- mobile, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100