Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/camera/camera_android_camerax/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Long> lastExposureTimeNs = new AtomicReference<>();

ImageCaptureProxyApi(@NonNull ProxyApiRegistrar pigeonRegistrar) {
super(pigeonRegistrar);
}
Expand All @@ -35,6 +51,7 @@ public ProxyApiRegistrar getPigeonRegistrar() {
return (ProxyApiRegistrar) super.getPigeonRegistrar();
}

@OptIn(markerClass = ExperimentalCamera2Interop.class)
@NonNull
@Override
public ImageCapture pigeon_defaultConstructor(
Expand Down Expand Up @@ -62,6 +79,22 @@ public ImageCapture pigeon_defaultConstructor(
if (resolutionSelector != null) {
builder.setResolutionSelector(resolutionSelector);
}

Camera2Interop.Extender<ImageCapture> 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();
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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<ExifInterface> 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<ExifInterface> 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<ExifInterface> 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<ImageCaptureProxyApi> mockedApi =
mockStatic(ImageCaptureProxyApi.class)) {
callback.onImageSaved(mock(ImageCapture.OutputFileResults.class));
mockedApi.verify(
() -> ImageCaptureProxyApi.patchExifExposureTime(mockFile, 123_000_000L));
}
assertEquals("test/path.jpg", result[0]);
}
}
2 changes: 1 addition & 1 deletion packages/camera/camera_android_camerax/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down