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 }):
- After the camera activity closes, the app freezes (UI + WebView) for several seconds before the
takePhoto() promise resolves.
- 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:
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.
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.
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.
savePictureInGallery (when saveToGallery: true): full-size copy into MediaStore, also on the main thread.
- 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
- Fresh Capacitor 8 Android app with
@capacitor/camera 8.2.0.
Camera.takePhoto({ quality: 60, targetWidth: 2500, targetHeight: 2500, saveToGallery: true, includeMetadata: true }) on a device with a high-resolution camera (tested 12–50 MP).
- 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.
Plugin version
@capacitor/camera8.2.0 (with bundledio.ionic.libs:ioncamera-android:1.0.1), Capacitor 8Platform
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 }):takePhoto()promise resolves.uri/webPathpoint at the unprocessed full-resolution capture file —qualityandtargetWidth/targetHeightare 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
ActivityResultLaunchercallback.IonCameraFlow.handleCameraResult→processResult()callscameraManager.processResultFromCamera(...)directly — unlikeprocessResultFromVideo/processResultFromGallery, which are wrapped inCoroutineScope(Dispatchers.Default).launch { ... }.On that main thread, for a single capture, the pipeline does:
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.imageHelper.compressBitmap(downsizedImage, 100): JPEG-encodes the bitmap at quality 100 into a byte array and immediatelyBitmapFactory.decodeByteArrays it back into a new bitmap. This round-trip has no observable effect except CPU time and allocations.imageHelper.bitmapToBase64(...): downsizes again, JPEG-encodes again (at the requested quality), and Base64-encodes the full ~2500px image into a string. This becomesMediaResult.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.savePictureInGallery(whensaveToGallery: true): full-size copy into MediaStore, also on the main thread.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 intoImageUtils.getExifData(context, bitmap, uri).Meanwhile the scaled/compressed bitmap from steps 1–2 is never written back to disk:
IONCAMRMediaResult.uriis the originalimageFilePath, so the resize/quality options only affect the (typically unused) base64thumbnail.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 withCameraResultType.Urinothing large crosses the bridge. Reverting our app fromtakePhoto()togetPhoto()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/targetHeightshould be applied to the file thaturi/webPathpoint at (or the docs should state they only affectthumbnail).thumbnailshould be opt-in, or at least skipped when the caller doesn't need it — it is by far the largest bridge payload.ExifInterfaceworks on the file/stream directly; the bitmap parameter is only used for dimensions, which are available fromOptions.inJustDecodeBounds).compressBitmap(bitmap, 100)encode/decode round-trip increateImageMediaResultappears to be dead work and could be removed.Steps to reproduce
@capacitor/camera8.2.0.Camera.takePhoto({ quality: 60, targetWidth: 2500, targetHeight: 2500, saveToGallery: true, includeMetadata: true })on a device with a high-resolution camera (tested 12–50 MP).webPathserving the full-resolution original.Other information
Since
getPhoto()is deprecated in favor oftakePhoto(), apps following the migration guide currently regress on Android in both latency and output file size. We're staying ongetPhoto()until this is addressed.