ionic-team / ionic-team/capacitor-camera

Android takePhoto(): seconds-long main-thread freeze after camera closes; quality/targetWidth silently ignored for the returned file

Open
#59 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.