From f7969252a3862aa6722064498c486f687e5e1ba0 Mon Sep 17 00:00:00 2001 From: Vadim Date: Sun, 22 Mar 2026 11:13:22 +0100 Subject: [PATCH] fix(camera_android_camerax): write exposure time to EXIF when missing Some devices (e.g. Honor) omit exposure time in JPEG EXIF when using CameraX ImageCapture.takePicture. Use Camera2Interop session capture callback to record SENSOR_EXPOSURE_TIME and patch EXIF after save via ExifInterface when TAG_EXPOSURE_TIME is absent. Adds androidx.exifinterface dependency and unit tests for patchExifExposureTime. Made-with: Cursor --- .../camera_android_camerax/CHANGELOG.md | 4 + .../android/build.gradle | 1 + .../plugins/camerax/ImageCaptureProxyApi.java | 52 +++++++++++ .../plugins/camerax/ImageCaptureTest.java | 86 +++++++++++++++++++ .../camera_android_camerax/pubspec.yaml | 2 +- 5 files changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/camera/camera_android_camerax/CHANGELOG.md b/packages/camera/camera_android_camerax/CHANGELOG.md index 4e9229a78da6..5039405d96cc 100644 --- a/packages/camera/camera_android_camerax/CHANGELOG.md +++ b/packages/camera/camera_android_camerax/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.7.1+1 + +* Writes exposure time to EXIF metadata when the device's JPEG encoder omits it. Uses Camera2 interop to capture `SENSOR_EXPOSURE_TIME` and patches EXIF after CameraX saves the file. + ## 0.7.1 * Removes outdated restrictions against concurrent camera use cases. diff --git a/packages/camera/camera_android_camerax/android/build.gradle b/packages/camera/camera_android_camerax/android/build.gradle index 065738fc7ba2..d42ea0509345 100644 --- a/packages/camera/camera_android_camerax/android/build.gradle +++ b/packages/camera/camera_android_camerax/android/build.gradle @@ -78,6 +78,7 @@ dependencies { implementation("androidx.camera:camera-lifecycle:${camerax_version}") implementation("androidx.camera:camera-video:${camerax_version}") implementation("com.google.guava:guava:33.5.0-android") + implementation("androidx.exifinterface:exifinterface:1.3.7") testImplementation("junit:junit:4.13.2") testImplementation("org.mockito:mockito-core:5.23.0") testImplementation("org.mockito:mockito-inline:5.2.0") diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageCaptureProxyApi.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageCaptureProxyApi.java index 7bf6bd508a25..9b0f66e74a4b 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageCaptureProxyApi.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageCaptureProxyApi.java @@ -4,14 +4,25 @@ package io.flutter.plugins.camerax; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.CaptureResult; +import android.hardware.camera2.TotalCaptureResult; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.annotation.VisibleForTesting; +import androidx.camera.camera2.interop.Camera2Interop; +import androidx.camera.camera2.interop.ExperimentalCamera2Interop; import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageCaptureException; import androidx.camera.core.resolutionselector.ResolutionSelector; +import androidx.exifinterface.media.ExifInterface; import java.io.File; import java.io.IOException; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; import kotlin.Result; import kotlin.Unit; import kotlin.jvm.functions.Function1; @@ -22,9 +33,14 @@ * native class or an instance of that class. */ class ImageCaptureProxyApi extends PigeonApiImageCapture { + private static final String TAG = "ImageCaptureProxyApi"; + static final String TEMPORARY_FILE_NAME = "CAP"; static final String JPG_FILE_TYPE = ".jpg"; + @VisibleForTesting + final AtomicReference lastExposureTimeNs = new AtomicReference<>(); + ImageCaptureProxyApi(@NonNull ProxyApiRegistrar pigeonRegistrar) { super(pigeonRegistrar); } @@ -35,6 +51,7 @@ public ProxyApiRegistrar getPigeonRegistrar() { return (ProxyApiRegistrar) super.getPigeonRegistrar(); } + @OptIn(markerClass = ExperimentalCamera2Interop.class) @NonNull @Override public ImageCapture pigeon_defaultConstructor( @@ -62,6 +79,22 @@ public ImageCapture pigeon_defaultConstructor( if (resolutionSelector != null) { builder.setResolutionSelector(resolutionSelector); } + + Camera2Interop.Extender extender = new Camera2Interop.Extender<>(builder); + extender.setSessionCaptureCallback( + new CameraCaptureSession.CaptureCallback() { + @Override + public void onCaptureCompleted( + @NonNull CameraCaptureSession session, + @NonNull CaptureRequest request, + @NonNull TotalCaptureResult result) { + Long exposureTime = result.get(CaptureResult.SENSOR_EXPOSURE_TIME); + if (exposureTime != null) { + lastExposureTimeNs.set(exposureTime); + } + } + }); + return builder.build(); } @@ -128,6 +161,7 @@ ImageCapture.OnImageSavedCallback createOnImageSavedCallback( return new ImageCapture.OnImageSavedCallback() { @Override public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { + patchExifExposureTime(file, lastExposureTimeNs.get()); ResultCompat.success(file.getAbsolutePath(), callback); } @@ -161,4 +195,22 @@ String getImageCaptureExceptionDescription(int imageCaptureErrorCode) { return "An unknown error has occurred while attempting to take a picture. Check the logs for more details."; } } + + @VisibleForTesting + static void patchExifExposureTime(@NonNull File file, @Nullable Long exposureTimeNs) { + if (exposureTimeNs == null) { + return; + } + try { + ExifInterface exif = new ExifInterface(file.getAbsolutePath()); + String existingExposureTime = exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME); + if (existingExposureTime == null || existingExposureTime.isEmpty()) { + double exposureTimeInSeconds = exposureTimeNs / 1_000_000_000.0; + exif.setAttribute(ExifInterface.TAG_EXPOSURE_TIME, String.valueOf(exposureTimeInSeconds)); + exif.saveAttributes(); + } + } catch (IOException e) { + Log.w(TAG, "Failed to write exposure time to EXIF", e); + } + } } diff --git a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageCaptureTest.java b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageCaptureTest.java index 5c84dff947df..0fa52b3ceb4a 100644 --- a/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageCaptureTest.java +++ b/packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageCaptureTest.java @@ -6,8 +6,11 @@ import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -18,19 +21,25 @@ import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageCaptureException; import androidx.camera.core.resolutionselector.ResolutionSelector; +import androidx.exifinterface.media.ExifInterface; import java.io.File; import java.io.IOException; import java.util.concurrent.Executor; import kotlin.Result; import kotlin.Unit; import kotlin.jvm.functions.Function1; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import org.robolectric.RobolectricTestRunner; @RunWith(RobolectricTestRunner.class) public class ImageCaptureTest { + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + @Test public void pigeon_defaultConstructor_createsImageCaptureWithCorrectConfiguration() { final PigeonApiImageCapture api = new TestProxyApiRegistrar().getPigeonApiImageCapture(); @@ -245,4 +254,81 @@ public void setTargetRotation_makesCallToSetTargetRotation() { verify(instance).setTargetRotation((int) rotation); } + + @Test + public void patchExifExposureTime_writesExposureTimeWhenMissing() throws IOException { + final File file = tempFolder.newFile("test.jpg"); + try (MockedConstruction mockedExif = + mockConstruction( + ExifInterface.class, + (mock, ctx) -> + when(mock.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)).thenReturn(null))) { + ImageCaptureProxyApi.patchExifExposureTime(file, 500_000_000L); + ExifInterface exif = mockedExif.constructed().get(0); + verify(exif).setAttribute(ExifInterface.TAG_EXPOSURE_TIME, String.valueOf(0.5)); + verify(exif).saveAttributes(); + } + } + + @Test + public void patchExifExposureTime_doesNotOverwriteExistingExposureTime() throws IOException { + final File file = tempFolder.newFile("test.jpg"); + try (MockedConstruction mockedExif = + mockConstruction( + ExifInterface.class, + (mock, ctx) -> + when(mock.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)).thenReturn("0.8"))) { + ImageCaptureProxyApi.patchExifExposureTime(file, 1_000_000_000L); + ExifInterface exif = mockedExif.constructed().get(0); + verify(exif).getAttribute(ExifInterface.TAG_EXPOSURE_TIME); + verify(exif, never()).setAttribute(eq(ExifInterface.TAG_EXPOSURE_TIME), any()); + verify(exif, never()).saveAttributes(); + } + } + + @Test + public void patchExifExposureTime_doesNothingWhenExposureTimeIsNull() throws IOException { + final File file = tempFolder.newFile("test.jpg"); + try (MockedConstruction mockedExif = + mockConstruction(ExifInterface.class)) { + ImageCaptureProxyApi.patchExifExposureTime(file, null); + assertEquals(0, mockedExif.constructed().size()); + } + } + + @Test + public void patchExifExposureTime_continuesOnExifError() { + final File nonExistentFile = new File("/non/existent/path.jpg"); + // Should not throw even when ExifInterface fails to open the file + ImageCaptureProxyApi.patchExifExposureTime(nonExistentFile, 1_000_000_000L); + } + + @Test + public void onImageSaved_callsPatchExifExposureTime() { + final ProxyApiRegistrar mockApiRegistrar = mock(ProxyApiRegistrar.class); + final ImageCaptureProxyApi api = new ImageCaptureProxyApi(mockApiRegistrar); + api.lastExposureTimeNs.set(123_000_000L); + + final File mockFile = mock(File.class); + when(mockFile.getAbsolutePath()).thenReturn("test/path.jpg"); + + final String[] result = {null}; + final ImageCapture.OnImageSavedCallback callback = + api.createOnImageSavedCallback( + mockFile, + mock(SystemServicesManager.class), + ResultCompat.asCompatCallback( + reply -> { + result[0] = reply.getOrNull(); + return null; + })); + + try (MockedStatic mockedApi = + mockStatic(ImageCaptureProxyApi.class)) { + callback.onImageSaved(mock(ImageCapture.OutputFileResults.class)); + mockedApi.verify( + () -> ImageCaptureProxyApi.patchExifExposureTime(mockFile, 123_000_000L)); + } + assertEquals("test/path.jpg", result[0]); + } } diff --git a/packages/camera/camera_android_camerax/pubspec.yaml b/packages/camera/camera_android_camerax/pubspec.yaml index 83ccda21ad1f..4125c02c589c 100644 --- a/packages/camera/camera_android_camerax/pubspec.yaml +++ b/packages/camera/camera_android_camerax/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_android_camerax description: Android implementation of the camera plugin using the CameraX library. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android_camerax issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.7.1 +version: 0.7.1+1 environment: sdk: ^3.9.0