Skip to content

Commit 481f171

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 b6e7d59 commit 481f171

5 files changed

Lines changed: 143 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.6.25+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.6.25
26

37
* Adds support for `MediaSettings.fps` for camera preview, image streaming, and video recording.

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.20.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

@@ -125,6 +158,7 @@ ImageCapture.OnImageSavedCallback createOnImageSavedCallback(
125158
return new ImageCapture.OnImageSavedCallback() {
126159
@Override
127160
public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
161+
patchExifExposureTime(file, lastExposureTimeNs.get());
128162
ResultCompat.success(file.getAbsolutePath(), callback);
129163
}
130164

@@ -134,4 +168,22 @@ public void onError(@NonNull ImageCaptureException exception) {
134168
}
135169
};
136170
}
171+
172+
@VisibleForTesting
173+
static void patchExifExposureTime(@NonNull File file, @Nullable Long exposureTimeNs) {
174+
if (exposureTimeNs == null) {
175+
return;
176+
}
177+
try {
178+
ExifInterface exif = new ExifInterface(file.getAbsolutePath());
179+
String existingExposureTime = exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME);
180+
if (existingExposureTime == null || existingExposureTime.isEmpty()) {
181+
double exposureTimeInSeconds = exposureTimeNs / 1_000_000_000.0;
182+
exif.setAttribute(ExifInterface.TAG_EXPOSURE_TIME, String.valueOf(exposureTimeInSeconds));
183+
exif.saveAttributes();
184+
}
185+
} catch (IOException e) {
186+
Log.w(TAG, "Failed to write exposure time to EXIF", e);
187+
}
188+
}
137189
}

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

Lines changed: 85 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();
@@ -234,4 +243,80 @@ public void setTargetRotation_makesCallToSetTargetRotation() {
234243

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

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.6.25
5+
version: 0.6.25+1
66

77
environment:
88
sdk: ^3.9.0

0 commit comments

Comments
 (0)