Skip to content

Commit f796925

Browse files
committed
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
1 parent 8dcfd11 commit f796925

5 files changed

Lines changed: 144 additions & 1 deletion

File tree

packages/camera/camera_android_camerax/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.7.1+1
2+
3+
* 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.
4+
15
## 0.7.1
26

37
* Removes outdated restrictions against concurrent camera use cases.

packages/camera/camera_android_camerax/android/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ dependencies {
7878
implementation("androidx.camera:camera-lifecycle:${camerax_version}")
7979
implementation("androidx.camera:camera-video:${camerax_version}")
8080
implementation("com.google.guava:guava:33.5.0-android")
81+
implementation("androidx.exifinterface:exifinterface:1.3.7")
8182
testImplementation("junit:junit:4.13.2")
8283
testImplementation("org.mockito:mockito-core:5.23.0")
8384
testImplementation("org.mockito:mockito-inline:5.2.0")

packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/ImageCaptureProxyApi.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,25 @@
44

55
package io.flutter.plugins.camerax;
66

7+
import android.hardware.camera2.CameraCaptureSession;
8+
import android.hardware.camera2.CaptureRequest;
9+
import android.hardware.camera2.CaptureResult;
10+
import android.hardware.camera2.TotalCaptureResult;
11+
import android.util.Log;
712
import androidx.annotation.NonNull;
813
import androidx.annotation.Nullable;
14+
import androidx.annotation.OptIn;
15+
import androidx.annotation.VisibleForTesting;
16+
import androidx.camera.camera2.interop.Camera2Interop;
17+
import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
918
import androidx.camera.core.ImageCapture;
1019
import androidx.camera.core.ImageCaptureException;
1120
import androidx.camera.core.resolutionselector.ResolutionSelector;
21+
import androidx.exifinterface.media.ExifInterface;
1222
import java.io.File;
1323
import java.io.IOException;
1424
import java.util.concurrent.Executors;
25+
import java.util.concurrent.atomic.AtomicReference;
1526
import kotlin.Result;
1627
import kotlin.Unit;
1728
import kotlin.jvm.functions.Function1;
@@ -22,9 +33,14 @@
2233
* native class or an instance of that class.
2334
*/
2435
class ImageCaptureProxyApi extends PigeonApiImageCapture {
36+
private static final String TAG = "ImageCaptureProxyApi";
37+
2538
static final String TEMPORARY_FILE_NAME = "CAP";
2639
static final String JPG_FILE_TYPE = ".jpg";
2740

41+
@VisibleForTesting
42+
final AtomicReference<Long> lastExposureTimeNs = new AtomicReference<>();
43+
2844
ImageCaptureProxyApi(@NonNull ProxyApiRegistrar pigeonRegistrar) {
2945
super(pigeonRegistrar);
3046
}
@@ -35,6 +51,7 @@ public ProxyApiRegistrar getPigeonRegistrar() {
3551
return (ProxyApiRegistrar) super.getPigeonRegistrar();
3652
}
3753

54+
@OptIn(markerClass = ExperimentalCamera2Interop.class)
3855
@NonNull
3956
@Override
4057
public ImageCapture pigeon_defaultConstructor(
@@ -62,6 +79,22 @@ public ImageCapture pigeon_defaultConstructor(
6279
if (resolutionSelector != null) {
6380
builder.setResolutionSelector(resolutionSelector);
6481
}
82+
83+
Camera2Interop.Extender<ImageCapture> extender = new Camera2Interop.Extender<>(builder);
84+
extender.setSessionCaptureCallback(
85+
new CameraCaptureSession.CaptureCallback() {
86+
@Override
87+
public void onCaptureCompleted(
88+
@NonNull CameraCaptureSession session,
89+
@NonNull CaptureRequest request,
90+
@NonNull TotalCaptureResult result) {
91+
Long exposureTime = result.get(CaptureResult.SENSOR_EXPOSURE_TIME);
92+
if (exposureTime != null) {
93+
lastExposureTimeNs.set(exposureTime);
94+
}
95+
}
96+
});
97+
6598
return builder.build();
6699
}
67100

@@ -128,6 +161,7 @@ ImageCapture.OnImageSavedCallback createOnImageSavedCallback(
128161
return new ImageCapture.OnImageSavedCallback() {
129162
@Override
130163
public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
164+
patchExifExposureTime(file, lastExposureTimeNs.get());
131165
ResultCompat.success(file.getAbsolutePath(), callback);
132166
}
133167

@@ -161,4 +195,22 @@ String getImageCaptureExceptionDescription(int imageCaptureErrorCode) {
161195
return "An unknown error has occurred while attempting to take a picture. Check the logs for more details.";
162196
}
163197
}
198+
199+
@VisibleForTesting
200+
static void patchExifExposureTime(@NonNull File file, @Nullable Long exposureTimeNs) {
201+
if (exposureTimeNs == null) {
202+
return;
203+
}
204+
try {
205+
ExifInterface exif = new ExifInterface(file.getAbsolutePath());
206+
String existingExposureTime = exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME);
207+
if (existingExposureTime == null || existingExposureTime.isEmpty()) {
208+
double exposureTimeInSeconds = exposureTimeNs / 1_000_000_000.0;
209+
exif.setAttribute(ExifInterface.TAG_EXPOSURE_TIME, String.valueOf(exposureTimeInSeconds));
210+
exif.saveAttributes();
211+
}
212+
} catch (IOException e) {
213+
Log.w(TAG, "Failed to write exposure time to EXIF", e);
214+
}
215+
}
164216
}

packages/camera/camera_android_camerax/android/src/test/java/io/flutter/plugins/camerax/ImageCaptureTest.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66

77
import static org.junit.Assert.assertEquals;
88
import static org.mockito.ArgumentMatchers.any;
9+
import static org.mockito.ArgumentMatchers.eq;
910
import static org.mockito.Mockito.mock;
11+
import static org.mockito.Mockito.mockConstruction;
1012
import static org.mockito.Mockito.mockStatic;
13+
import static org.mockito.Mockito.never;
1114
import static org.mockito.Mockito.verify;
1215
import static org.mockito.Mockito.verifyNoInteractions;
1316
import static org.mockito.Mockito.when;
@@ -18,19 +21,25 @@
1821
import androidx.camera.core.ImageCapture;
1922
import androidx.camera.core.ImageCaptureException;
2023
import androidx.camera.core.resolutionselector.ResolutionSelector;
24+
import androidx.exifinterface.media.ExifInterface;
2125
import java.io.File;
2226
import java.io.IOException;
2327
import java.util.concurrent.Executor;
2428
import kotlin.Result;
2529
import kotlin.Unit;
2630
import kotlin.jvm.functions.Function1;
31+
import org.junit.Rule;
2732
import org.junit.Test;
33+
import org.junit.rules.TemporaryFolder;
2834
import org.junit.runner.RunWith;
35+
import org.mockito.MockedConstruction;
2936
import org.mockito.MockedStatic;
3037
import org.robolectric.RobolectricTestRunner;
3138

3239
@RunWith(RobolectricTestRunner.class)
3340
public class ImageCaptureTest {
41+
@Rule public TemporaryFolder tempFolder = new TemporaryFolder();
42+
3443
@Test
3544
public void pigeon_defaultConstructor_createsImageCaptureWithCorrectConfiguration() {
3645
final PigeonApiImageCapture api = new TestProxyApiRegistrar().getPigeonApiImageCapture();
@@ -245,4 +254,81 @@ public void setTargetRotation_makesCallToSetTargetRotation() {
245254

246255
verify(instance).setTargetRotation((int) rotation);
247256
}
257+
258+
@Test
259+
public void patchExifExposureTime_writesExposureTimeWhenMissing() throws IOException {
260+
final File file = tempFolder.newFile("test.jpg");
261+
try (MockedConstruction<ExifInterface> mockedExif =
262+
mockConstruction(
263+
ExifInterface.class,
264+
(mock, ctx) ->
265+
when(mock.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)).thenReturn(null))) {
266+
ImageCaptureProxyApi.patchExifExposureTime(file, 500_000_000L);
267+
ExifInterface exif = mockedExif.constructed().get(0);
268+
verify(exif).setAttribute(ExifInterface.TAG_EXPOSURE_TIME, String.valueOf(0.5));
269+
verify(exif).saveAttributes();
270+
}
271+
}
272+
273+
@Test
274+
public void patchExifExposureTime_doesNotOverwriteExistingExposureTime() throws IOException {
275+
final File file = tempFolder.newFile("test.jpg");
276+
try (MockedConstruction<ExifInterface> mockedExif =
277+
mockConstruction(
278+
ExifInterface.class,
279+
(mock, ctx) ->
280+
when(mock.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)).thenReturn("0.8"))) {
281+
ImageCaptureProxyApi.patchExifExposureTime(file, 1_000_000_000L);
282+
ExifInterface exif = mockedExif.constructed().get(0);
283+
verify(exif).getAttribute(ExifInterface.TAG_EXPOSURE_TIME);
284+
verify(exif, never()).setAttribute(eq(ExifInterface.TAG_EXPOSURE_TIME), any());
285+
verify(exif, never()).saveAttributes();
286+
}
287+
}
288+
289+
@Test
290+
public void patchExifExposureTime_doesNothingWhenExposureTimeIsNull() throws IOException {
291+
final File file = tempFolder.newFile("test.jpg");
292+
try (MockedConstruction<ExifInterface> mockedExif =
293+
mockConstruction(ExifInterface.class)) {
294+
ImageCaptureProxyApi.patchExifExposureTime(file, null);
295+
assertEquals(0, mockedExif.constructed().size());
296+
}
297+
}
298+
299+
@Test
300+
public void patchExifExposureTime_continuesOnExifError() {
301+
final File nonExistentFile = new File("/non/existent/path.jpg");
302+
// Should not throw even when ExifInterface fails to open the file
303+
ImageCaptureProxyApi.patchExifExposureTime(nonExistentFile, 1_000_000_000L);
304+
}
305+
306+
@Test
307+
public void onImageSaved_callsPatchExifExposureTime() {
308+
final ProxyApiRegistrar mockApiRegistrar = mock(ProxyApiRegistrar.class);
309+
final ImageCaptureProxyApi api = new ImageCaptureProxyApi(mockApiRegistrar);
310+
api.lastExposureTimeNs.set(123_000_000L);
311+
312+
final File mockFile = mock(File.class);
313+
when(mockFile.getAbsolutePath()).thenReturn("test/path.jpg");
314+
315+
final String[] result = {null};
316+
final ImageCapture.OnImageSavedCallback callback =
317+
api.createOnImageSavedCallback(
318+
mockFile,
319+
mock(SystemServicesManager.class),
320+
ResultCompat.asCompatCallback(
321+
reply -> {
322+
result[0] = reply.getOrNull();
323+
return null;
324+
}));
325+
326+
try (MockedStatic<ImageCaptureProxyApi> mockedApi =
327+
mockStatic(ImageCaptureProxyApi.class)) {
328+
callback.onImageSaved(mock(ImageCapture.OutputFileResults.class));
329+
mockedApi.verify(
330+
() -> ImageCaptureProxyApi.patchExifExposureTime(mockFile, 123_000_000L));
331+
}
332+
assertEquals("test/path.jpg", result[0]);
333+
}
248334
}

packages/camera/camera_android_camerax/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: camera_android_camerax
22
description: Android implementation of the camera plugin using the CameraX library.
33
repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android_camerax
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22
5-
version: 0.7.1
5+
version: 0.7.1+1
66

77
environment:
88
sdk: ^3.9.0

0 commit comments

Comments
 (0)