Skip to content

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

Description

@maximilianschmid

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 filequality 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.handleCameraResultprocessResult() 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.decodeByteArrays 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions