Merge changes I7bc72709,I41b2b909,I38f520c7,I5bd6ae14,Ic80cd9e0 into androidx-main
* changes:
Disable flash in Camera2CapturePipeline when low-light boost is on
Implement CameraInfo#isLowLightBoostSupported and getLowLightBoostState API in camera-camera2
Implement CameraControl#enableLowLightBoost API in camera-camera2
Add Low Light Boost related API in CameraControl and CameraInfo interfaces
Upgrade compileSdk as 35 for LLB feature development
diff --git a/camera/camera-camera2-pipe-integration/build.gradle b/camera/camera-camera2-pipe-integration/build.gradle
index bb710ed..eccd134 100644
--- a/camera/camera-camera2-pipe-integration/build.gradle
+++ b/camera/camera-camera2-pipe-integration/build.gradle
@@ -85,6 +85,8 @@
}
android {
+ compileSdk 35
+
lintOptions {
enable("CameraXQuirksClassDetector")
}
diff --git a/camera/camera-camera2/build.gradle b/camera/camera-camera2/build.gradle
index cf174ef..45bc114 100644
--- a/camera/camera-camera2/build.gradle
+++ b/camera/camera-camera2/build.gradle
@@ -81,6 +81,8 @@
androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0")
}
android {
+ compileSdk 35
+
buildTypes.configureEach {
consumerProguardFiles("proguard-rules.pro")
}
diff --git a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2CameraControlImplDeviceTest.java b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2CameraControlImplDeviceTest.java
index d43e3f6..96d36ea 100644
--- a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2CameraControlImplDeviceTest.java
+++ b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/Camera2CameraControlImplDeviceTest.java
@@ -21,6 +21,7 @@
import static android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_ALWAYS_FLASH;
import static android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_AUTO_FLASH;
import static android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_EXTERNAL_FLASH;
+import static android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY;
import static android.hardware.camera2.CameraMetadata.CONTROL_AF_MODE_AUTO;
import static android.hardware.camera2.CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_PICTURE;
import static android.hardware.camera2.CameraMetadata.CONTROL_AF_MODE_OFF;
@@ -397,20 +398,8 @@
public void enableTorch_aeModeSetAndRequestUpdated() throws InterruptedException {
assumeTrue(mHasFlashUnit);
mCamera2CameraControlImpl.enableTorch(true);
-
HandlerUtil.waitForLooperToIdle(mHandler);
-
- verify(mControlUpdateCallback, times(1)).onCameraControlUpdateSessionConfig();
- SessionConfig sessionConfig = mCamera2CameraControlImpl.getSessionConfig();
- Camera2ImplConfig camera2Config = new Camera2ImplConfig(
- sessionConfig.getImplementationOptions());
-
- assertAeMode(camera2Config, CONTROL_AE_MODE_ON);
-
- assertThat(
- camera2Config.getCaptureRequestOption(
- CaptureRequest.FLASH_MODE, FLASH_MODE_OFF))
- .isEqualTo(FLASH_MODE_TORCH);
+ verifyControlAeModeAndFlashMode(CONTROL_AE_MODE_ON, FLASH_MODE_TORCH);
}
@Test
@@ -442,6 +431,47 @@
}
+ @SdkSuppress(minSdkVersion = 35)
+ @Test
+ public void enableLowLightBoost_aeModeSetAndRequestUpdated() throws InterruptedException {
+ assumeTrue(mCamera2CameraControlImpl.getLowLightBoostControl().isLowLightBoostSupported());
+ mCamera2CameraControlImpl.enableLowLightBoostAsync(true);
+ HandlerUtil.waitForLooperToIdle(mHandler);
+ verifyControlAeModeAndFlashMode(CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY,
+ FLASH_MODE_OFF);
+ }
+
+ @SdkSuppress(minSdkVersion = 35)
+ @Test
+ public void enableLowLightBoostCanOverrideTorch_aeModeSetAndRequestUpdated()
+ throws InterruptedException {
+ assumeTrue(mCamera2CameraControlImpl.getLowLightBoostControl().isLowLightBoostSupported());
+ assumeTrue(mHasFlashUnit);
+
+ mCamera2CameraControlImpl.enableTorch(true);
+ HandlerUtil.waitForLooperToIdle(mHandler);
+ verifyControlAeModeAndFlashMode(CONTROL_AE_MODE_ON, FLASH_MODE_TORCH);
+
+ mCamera2CameraControlImpl.enableTorch(true);
+ HandlerUtil.waitForLooperToIdle(mHandler);
+ verifyControlAeModeAndFlashMode(CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY,
+ FLASH_MODE_OFF);
+ }
+
+ private void verifyControlAeModeAndFlashMode(int expectedAeMode, int expectedFlashMode) {
+ verify(mControlUpdateCallback, times(1)).onCameraControlUpdateSessionConfig();
+ SessionConfig sessionConfig = mCamera2CameraControlImpl.getSessionConfig();
+ Camera2ImplConfig camera2Config = new Camera2ImplConfig(
+ sessionConfig.getImplementationOptions());
+
+ assertAeMode(camera2Config, expectedAeMode);
+
+ assertThat(
+ camera2Config.getCaptureRequestOption(
+ CaptureRequest.FLASH_MODE, FLASH_MODE_OFF))
+ .isEqualTo(expectedFlashMode);
+ }
+
@Test
@LargeTest
public void triggerAf_futureSucceeds() throws Exception {
diff --git a/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/LowLightBoostControlDeviceTest.kt b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/LowLightBoostControlDeviceTest.kt
new file mode 100644
index 0000000..b0a8f1d
--- /dev/null
+++ b/camera/camera-camera2/src/androidTest/java/androidx/camera/camera2/internal/LowLightBoostControlDeviceTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.internal
+
+import android.content.Context
+import androidx.camera.camera2.Camera2Config
+import androidx.camera.camera2.internal.util.TestUtil
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.Preview
+import androidx.camera.core.internal.CameraUseCaseAdapter
+import androidx.camera.testing.impl.CameraUtil
+import androidx.camera.testing.impl.CameraUtil.PreTestCameraIdList
+import androidx.camera.testing.impl.CameraXUtil
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import java.util.concurrent.TimeUnit
+import org.junit.After
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private val DEFAULT_CAMERA_SELECTOR = CameraSelector.DEFAULT_BACK_CAMERA
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = 35)
+class LowLightBoostControlDeviceTest {
+
+ @get:Rule
+ val cameraRule =
+ CameraUtil.grantCameraPermissionAndPreTestAndPostTest(
+ PreTestCameraIdList(Camera2Config.defaultConfig())
+ )
+
+ val context = ApplicationProvider.getApplicationContext() as Context
+
+ private lateinit var camera: CameraUseCaseAdapter
+
+ @Before
+ fun setUp() {
+ assumeTrue(CameraUtil.hasCameraWithLensFacing(DEFAULT_CAMERA_SELECTOR.lensFacing!!))
+ // Init CameraX
+ val config = Camera2Config.defaultConfig()
+ CameraXUtil.initialize(context, config)
+
+ // Prepare LowLightBoostControl
+ // To get a functional Camera2CameraControlImpl, it needs to bind an active UseCase and the
+ // UseCase must have repeating surface. Create and bind Preview as repeating surface.
+ camera =
+ CameraUtil.createCameraAndAttachUseCase(
+ context,
+ DEFAULT_CAMERA_SELECTOR,
+ Preview.Builder().build()
+ )
+ }
+
+ @After
+ fun tearDown() {
+ if (::camera.isInitialized) {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ // TODO: The removeUseCases() call might be removed after clarifying the
+ // abortCaptures() issue in b/162314023.
+ camera.removeUseCases(camera.getUseCases())
+ }
+ }
+
+ CameraXUtil.shutdown().get(10000, TimeUnit.MILLISECONDS)
+ }
+
+ @Test
+ fun enableDisableLowLightBoost_futureWillCompleteSuccessfully_whenLlbIsSupported() {
+ assumeTrue(camera.cameraInfo.isLowLightBoostSupported)
+ TestUtil.getCamera2CameraControlImpl(camera.cameraControl).lowLightBoostControl.apply {
+ // Future should return with no exception
+ enableLowLightBoost(true).get(3, TimeUnit.SECONDS)
+
+ // Future should return with no exception
+ enableLowLightBoost(false).get(3, TimeUnit.SECONDS)
+ }
+ }
+}
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
index 2b9b8a2..ef38ab5 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraControlImpl.java
@@ -16,6 +16,8 @@
package androidx.camera.camera2.internal;
+import static android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY;
+
import static androidx.camera.core.ImageCapture.FLASH_MODE_AUTO;
import static androidx.camera.core.ImageCapture.FLASH_MODE_OFF;
import static androidx.camera.core.ImageCapture.FLASH_MODE_ON;
@@ -124,6 +126,7 @@
private final FocusMeteringControl mFocusMeteringControl;
private final ZoomControl mZoomControl;
private final TorchControl mTorchControl;
+ private final LowLightBoostControl mLowLightBoostControl;
private final ExposureControl mExposureControl;
@VisibleForTesting
ZslControl mZslControl;
@@ -137,6 +140,7 @@
// use volatile modifier to make these variables in sync in all threads.
private volatile boolean mIsTorchOn = false;
+ private volatile boolean mIsLowLightBoostOn = false;
@ImageCapture.FlashMode
private volatile int mFlashMode = FLASH_MODE_OFF;
@@ -201,6 +205,7 @@
this, scheduler, mExecutor, cameraQuirks);
mZoomControl = new ZoomControl(this, mCameraCharacteristics, mExecutor);
mTorchControl = new TorchControl(this, mCameraCharacteristics, mExecutor);
+ mLowLightBoostControl = new LowLightBoostControl(this, mCameraCharacteristics, mExecutor);
if (Build.VERSION.SDK_INT >= 23) {
mZslControl = new ZslControlImpl(mCameraCharacteristics, mExecutor);
} else {
@@ -262,6 +267,10 @@
return mTorchControl;
}
+ public @NonNull LowLightBoostControl getLowLightBoostControl() {
+ return mLowLightBoostControl;
+ }
+
public @NonNull ExposureControl getExposureControl() {
return mExposureControl;
}
@@ -307,6 +316,7 @@
Logger.d(TAG, "setActive: isActive = " + isActive);
mFocusMeteringControl.setActive(isActive);
mZoomControl.setActive(isActive);
+ mLowLightBoostControl.setActive(isActive);
mTorchControl.setActive(isActive);
mExposureControl.setActive(isActive);
mCamera2CameraControl.setActive(isActive);
@@ -424,6 +434,17 @@
return Futures.nonCancellationPropagating(mTorchControl.enableTorch(torch));
}
+ /** {@inheritDoc} */
+ @Override
+ public @NonNull ListenableFuture<Void> enableLowLightBoostAsync(final boolean lowLightBoost) {
+ if (!isControlInUse()) {
+ return Futures.immediateFailedFuture(
+ new OperationCanceledException("Camera is not active."));
+ }
+ return Futures.nonCancellationPropagating(
+ mLowLightBoostControl.enableLowLightBoost(lowLightBoost));
+ }
+
@ExecutedBy("mExecutor")
private @NonNull ListenableFuture<Void> waitForSessionUpdateId(long sessionUpdateIdToWait) {
return CallbackToFutureAdapter.getFuture(completer -> {
@@ -623,30 +644,66 @@
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ExecutedBy("mExecutor")
void enableTorchInternal(boolean torch) {
+ // When low-light boost is on, any torch related operations will be ignored.
+ if (mIsLowLightBoostOn) {
+ return;
+ }
+
mIsTorchOn = torch;
if (!torch) {
- // Send capture request with AE_MODE_ON + FLASH_MODE_OFF to turn off torch.
- CaptureConfig.Builder singleRequestBuilder = new CaptureConfig.Builder();
- singleRequestBuilder.setTemplateType(mTemplate);
- singleRequestBuilder.setUseRepeatingSurface(true);
- Camera2ImplConfig.Builder configBuilder = new Camera2ImplConfig.Builder();
- configBuilder.setCaptureRequestOption(CaptureRequest.CONTROL_AE_MODE,
- getSupportedAeMode(CaptureRequest.CONTROL_AE_MODE_ON));
- configBuilder.setCaptureRequestOption(CaptureRequest.FLASH_MODE,
- CaptureRequest.FLASH_MODE_OFF);
- singleRequestBuilder.addImplementationOptions(configBuilder.build());
- submitCaptureRequestsInternal(
- Collections.singletonList(singleRequestBuilder.build()));
+ // On some devices, needs to reset the AE/flash state to ensure that the torch can be
+ // turned off.
+ resetAeFlashState();
}
updateSessionConfigSynchronous();
}
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ @ExecutedBy("mExecutor")
+ void enableLowLightBoostInternal(boolean lowLightBoost) {
+ if (mIsLowLightBoostOn == lowLightBoost) {
+ return;
+ }
+
+ // Forces turn off torch before enabling low-light boost.
+ if (lowLightBoost && mIsTorchOn) {
+ // On some devices, needs to reset the AE/flash state to ensure that the torch can be
+ // turned off.
+ resetAeFlashState();
+ mIsTorchOn = false;
+ mTorchControl.forceUpdateTorchStateToOff();
+ }
+
+ mIsLowLightBoostOn = lowLightBoost;
+ updateSessionConfigSynchronous();
+ }
+
+ private void resetAeFlashState() {
+ // Send capture request with AE_MODE_ON + FLASH_MODE_OFF to reset the AE/flash state.
+ CaptureConfig.Builder singleRequestBuilder = new CaptureConfig.Builder();
+ singleRequestBuilder.setTemplateType(mTemplate);
+ singleRequestBuilder.setUseRepeatingSurface(true);
+ Camera2ImplConfig.Builder configBuilder = new Camera2ImplConfig.Builder();
+ configBuilder.setCaptureRequestOption(CaptureRequest.CONTROL_AE_MODE,
+ getSupportedAeMode(CaptureRequest.CONTROL_AE_MODE_ON));
+ configBuilder.setCaptureRequestOption(CaptureRequest.FLASH_MODE,
+ CaptureRequest.FLASH_MODE_OFF);
+ singleRequestBuilder.addImplementationOptions(configBuilder.build());
+ submitCaptureRequestsInternal(
+ Collections.singletonList(singleRequestBuilder.build()));
+ }
+
@ExecutedBy("mExecutor")
boolean isTorchOn() {
return mIsTorchOn;
}
@ExecutedBy("mExecutor")
+ boolean isLowLightBoostOn() {
+ return mIsLowLightBoostOn;
+ }
+
+ @ExecutedBy("mExecutor")
void submitCaptureRequestsInternal(final List<CaptureConfig> captureConfigs) {
mControlUpdateCallback.onCameraControlCaptureRequests(captureConfigs);
}
@@ -676,7 +733,9 @@
aeMode = CaptureRequest.CONTROL_AE_MODE_ON_EXTERNAL_FLASH;
}
- if (mIsTorchOn) {
+ if (mIsLowLightBoostOn) {
+ aeMode = CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY;
+ } else if (mIsTorchOn) {
builder.setCaptureRequestOptionWithPriority(CaptureRequest.FLASH_MODE,
CaptureRequest.FLASH_MODE_TORCH, Config.OptionPriority.REQUIRED);
} else {
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java
index 65ed945..81a5d90 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CameraInfoImpl.java
@@ -110,6 +110,8 @@
@GuardedBy("mLock")
private @Nullable RedirectableLiveData<Integer> mRedirectTorchStateLiveData = null;
@GuardedBy("mLock")
+ private @Nullable RedirectableLiveData<Integer> mRedirectLowLightBoostStateLiveData = null;
+ @GuardedBy("mLock")
private @Nullable RedirectableLiveData<ZoomState> mRedirectZoomStateLiveData = null;
private final @NonNull RedirectableLiveData<CameraState> mCameraStateLiveData;
@GuardedBy("mLock")
@@ -159,6 +161,11 @@
mCamera2CameraControlImpl.getTorchControl().getTorchState());
}
+ if (mRedirectLowLightBoostStateLiveData != null) {
+ mRedirectLowLightBoostStateLiveData.redirectTo(mCamera2CameraControlImpl
+ .getLowLightBoostControl().getLowLightBoostState());
+ }
+
if (mCameraCaptureCallbacks != null) {
for (Pair<CameraCaptureCallback, Executor> pair :
mCameraCaptureCallbacks) {
@@ -291,6 +298,31 @@
}
@Override
+ public boolean isLowLightBoostSupported() {
+ return LowLightBoostControl.checkLowLightBoostAvailability(mCameraCharacteristicsCompat);
+ }
+
+ @Override
+ public @NonNull LiveData<Integer> getLowLightBoostState() {
+ synchronized (mLock) {
+ if (mCamera2CameraControlImpl == null) {
+ if (mRedirectLowLightBoostStateLiveData == null) {
+ mRedirectLowLightBoostStateLiveData =
+ new RedirectableLiveData<>(LowLightBoostControl.DEFAULT_LLB_STATE);
+ }
+ return mRedirectLowLightBoostStateLiveData;
+ }
+
+ // if RedirectableLiveData exists, use it directly.
+ if (mRedirectLowLightBoostStateLiveData != null) {
+ return mRedirectLowLightBoostStateLiveData;
+ }
+
+ return mCamera2CameraControlImpl.getLowLightBoostControl().getLowLightBoostState();
+ }
+ }
+
+ @Override
public @NonNull LiveData<ZoomState> getZoomState() {
synchronized (mLock) {
if (mCamera2CameraControlImpl == null) {
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java
index d000354..42a38eb 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/Camera2CapturePipeline.java
@@ -326,7 +326,8 @@
null) : Futures.immediateFuture(null);
preCapture = FutureChain.from(getResult).transformAsync(captureResult -> {
- if (isFlashRequired(flashMode, captureResult)) {
+ if (!mCameraControl.isLowLightBoostOn() && isFlashRequired(flashMode,
+ captureResult)) {
setTimeout3A(CHECK_3A_WITH_FLASH_TIMEOUT_IN_NS);
}
return mPipelineSubTask.preCapture(captureResult);
@@ -625,7 +626,9 @@
Logger.d(TAG, "TorchTask#preCapture: isFlashRequired = " + isFlashRequired);
if (isFlashRequired(mFlashMode, captureResult)) {
- if (mCameraControl.isTorchOn()) {
+ if (mCameraControl.isLowLightBoostOn()) {
+ Logger.d(TAG, "Low-light boost already on, not turn on");
+ } else if (mCameraControl.isTorchOn()) {
Logger.d(TAG, "Torch already on, not turn on");
} else {
Logger.d(TAG, "Turn on torch");
@@ -694,7 +697,7 @@
@Override
public @NonNull ListenableFuture<Boolean> preCapture(
@Nullable TotalCaptureResult captureResult) {
- if (isFlashRequired(mFlashMode, captureResult)) {
+ if (!mCameraControl.isLowLightBoostOn() && isFlashRequired(mFlashMode, captureResult)) {
Logger.d(TAG, "Trigger AE");
mIsExecuted = true;
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/LowLightBoostControl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/LowLightBoostControl.java
new file mode 100644
index 0000000..78bcb35
--- /dev/null
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/LowLightBoostControl.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.internal;
+
+import static android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY;
+import static android.hardware.camera2.CaptureResult.CONTROL_LOW_LIGHT_BOOST_STATE;
+
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CaptureRequest;
+import android.os.Build;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.camera.camera2.internal.annotation.CameraExecutor;
+import androidx.camera.camera2.internal.compat.CameraCharacteristicsCompat;
+import androidx.camera.core.CameraControl;
+import androidx.camera.core.Logger;
+import androidx.camera.core.LowLightBoostState;
+import androidx.camera.core.impl.annotation.ExecutedBy;
+import androidx.camera.core.impl.utils.Threads;
+import androidx.camera.core.impl.utils.futures.Futures;
+import androidx.concurrent.futures.CallbackToFutureAdapter;
+import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.util.concurrent.Executor;
+
+/**
+ * Implementation of low-light boost control used within CameraControl and CameraInfo.
+ *
+ * It is used to control the low-light boost mode of camera device that
+ * {@link Camera2CameraControlImpl} operates. The low-light boost control must be activated via
+ * {@link #setActive(boolean)} when the camera device is ready to do low-light boost operations
+ * and be deactivated when the camera device is closing or closed.
+ */
+final class LowLightBoostControl {
+ private static final String TAG = "LowLightBoostControl";
+ static final int DEFAULT_LLB_STATE = LowLightBoostState.OFF;
+
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ private final Camera2CameraControlImpl mCamera2CameraControlImpl;
+ private final MutableLiveData<Integer> mLowLightBoostState;
+ private final boolean mIsLowLightBoostSupported;
+ @CameraExecutor
+ private final Executor mExecutor;
+
+ private boolean mIsActive;
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ CallbackToFutureAdapter.Completer<Void> mEnableLlbCompleter;
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ boolean mTargetLlbEnabled;
+
+ @VisibleForTesting
+ final Camera2CameraControlImpl.CaptureResultListener mCaptureResultListener;
+
+ /**
+ * Constructs a LowLightBoostControl.
+ *
+ * @param camera2CameraControlImpl the camera control this LowLightBoostControl belongs.
+ * @param cameraCharacteristics the characteristics for the camera being controlled.
+ * @param executor the camera executor used to run camera task.
+ */
+ LowLightBoostControl(@NonNull Camera2CameraControlImpl camera2CameraControlImpl,
+ @NonNull CameraCharacteristicsCompat cameraCharacteristics,
+ @CameraExecutor @NonNull Executor executor) {
+ mCamera2CameraControlImpl = camera2CameraControlImpl;
+ mExecutor = executor;
+
+ mIsLowLightBoostSupported = checkLowLightBoostAvailability(cameraCharacteristics);
+ mLowLightBoostState = new MutableLiveData<>(DEFAULT_LLB_STATE);
+
+ mCaptureResultListener = captureResult -> {
+ if (mEnableLlbCompleter != null) {
+ CaptureRequest captureRequest = captureResult.getRequest();
+ Integer aeMode = captureRequest.get(CaptureRequest.CONTROL_AE_MODE);
+
+ // Skips the check if capture result doesn't contain AE mode related info.
+ if (aeMode == null) {
+ return false;
+ }
+
+ // mTargetLlbEnabled might be either true or false.
+ // - When mTargetLlbEnabled is true: complete the completer when
+ // AE mode becomes CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY.
+ // - When mTargetLlbEnabled is false: complete the completer when
+ // AE mode becomes non-CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY.
+ boolean llbEnabled =
+ aeMode == CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY;
+ if (llbEnabled == mTargetLlbEnabled) {
+ mEnableLlbCompleter.set(null);
+ mEnableLlbCompleter = null;
+ } else {
+ return false;
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
+ && mTargetLlbEnabled) {
+ Integer currentState = captureResult.get(CONTROL_LOW_LIGHT_BOOST_STATE);
+ if (currentState != null) {
+ setLiveDataValue(mLowLightBoostState, currentState);
+ }
+ }
+
+ // Return false to keep getting captureResult.
+ return false;
+ };
+
+ if (mIsLowLightBoostSupported) {
+ mCamera2CameraControlImpl.addCaptureResultListener(mCaptureResultListener);
+ }
+ }
+
+ /**
+ * Set current active state. Set active if it is ready to do low-light boost operations.
+ *
+ * @param isActive true to activate or false otherwise.
+ */
+ @ExecutedBy("mExecutor")
+ void setActive(boolean isActive) {
+ if (mIsActive == isActive) {
+ return;
+ }
+
+ mIsActive = isActive;
+
+ if (!isActive) {
+ if (mTargetLlbEnabled) {
+ mTargetLlbEnabled = false;
+ mCamera2CameraControlImpl.enableLowLightBoostInternal(false);
+ setLiveDataValue(mLowLightBoostState, LowLightBoostState.OFF);
+ }
+
+ if (mEnableLlbCompleter != null) {
+ mEnableLlbCompleter.setException(
+ new CameraControl.OperationCanceledException("Camera is not active."));
+ mEnableLlbCompleter = null;
+ }
+ }
+ }
+
+ /**
+ * Enable or disable the low-light boost.
+ *
+ * <p>The returned {@link ListenableFuture} will succeed when the request is sent to camera
+ * device. But it may get an {@link CameraControl.OperationCanceledException} result when:
+ * <ol>
+ * <li>There are multiple {@code enableLowLightBoost(boolean)} requests in the same time, the
+ * older and incomplete futures will get cancelled.
+ * <li>When the LowLightBoostControl is set to inactive.
+ * </ol>
+ *
+ * <p>The returned {@link ListenableFuture} will fail immediately when:
+ * <ol>
+ * <li>The LowLightBoostControl is not in active state.
+ * <li>The camera doesn't support low-light boost mode.
+ * </ol>
+ *
+ * @param enabled true to open the low-light boost, false to close it.
+ * @return A {@link ListenableFuture} which is successful when the low-light boost was changed
+ * to the value specified. It fails when it is unable to change the low-light boost state.
+ */
+ ListenableFuture<Void> enableLowLightBoost(boolean enabled) {
+ if (!mIsLowLightBoostSupported) {
+ Logger.d(TAG, "Unable to enable low-light boost due to it is not supported.");
+ return Futures.immediateFailedFuture(
+ new IllegalStateException("Low-light boost is not supported"));
+ }
+
+ setLiveDataValue(mLowLightBoostState,
+ enabled ? LowLightBoostState.INACTIVE : LowLightBoostState.OFF);
+
+ return CallbackToFutureAdapter.getFuture(completer -> {
+ mExecutor.execute(() -> enableLowLightBoostInternal(completer, enabled));
+ return "enableLowLightBoost: " + enabled;
+ });
+ }
+
+ /**
+ * Returns a {@link LiveData} of current {@link LowLightBoostState}.
+ *
+ * <p>The low-light boost state can be enabled or disabled via
+ * {@link #enableLowLightBoost(boolean)} which will trigger the change event to the returned
+ * {@link LiveData}.
+ *
+ * @return a {@link LiveData} containing current low-light boost state.
+ */
+ @NonNull
+ LiveData<Integer> getLowLightBoostState() {
+ return mLowLightBoostState;
+ }
+
+ @SuppressWarnings("WeakerAccess") /* synthetic accessor */
+ @ExecutedBy("mExecutor")
+ void enableLowLightBoostInternal(@Nullable Completer<Void> completer, boolean enabled) {
+ if (!mIsActive) {
+ setLiveDataValue(mLowLightBoostState, LowLightBoostState.OFF);
+ if (completer != null) {
+ completer.setException(
+ new CameraControl.OperationCanceledException("Camera is not active."));
+ }
+ return;
+ }
+
+ mTargetLlbEnabled = enabled;
+ mCamera2CameraControlImpl.enableLowLightBoostInternal(enabled);
+ setLiveDataValue(mLowLightBoostState,
+ enabled ? LowLightBoostState.INACTIVE : LowLightBoostState.OFF);
+ if (mEnableLlbCompleter != null) {
+ mEnableLlbCompleter.setException(new CameraControl.OperationCanceledException(
+ "There is a new enableLowLightBoost being set"));
+ }
+ mEnableLlbCompleter = completer;
+ }
+
+ boolean isLowLightBoostSupported() {
+ return mIsLowLightBoostSupported;
+ }
+
+ static boolean checkLowLightBoostAvailability(
+ @NonNull CameraCharacteristicsCompat cameraCharacteristics) {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ return false;
+ }
+
+ int[] availableAeModes = cameraCharacteristics.get(
+ CameraCharacteristics.CONTROL_AE_AVAILABLE_MODES);
+ if (availableAeModes != null) {
+ for (int availableAeMode : availableAeModes) {
+ if (availableAeMode
+ == CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private <T> void setLiveDataValue(@NonNull MutableLiveData<T> liveData, T value) {
+ if (Threads.isMainThread()) {
+ liveData.setValue(value);
+ } else {
+ liveData.postValue(value);
+ }
+ }
+}
diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/TorchControl.java b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/TorchControl.java
index c18d7b0..d4c634e 100644
--- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/TorchControl.java
+++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/internal/TorchControl.java
@@ -61,9 +61,9 @@
private boolean mIsActive;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
- CallbackToFutureAdapter.Completer<Void> mEnableTorchCompleter;
+ CallbackToFutureAdapter.Completer<Void> mEnableTorchCompleter;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
- boolean mTargetTorchEnabled;
+ boolean mTargetTorchEnabled;
/**
* Constructs a TorchControl.
@@ -193,6 +193,14 @@
return;
}
+ if (mCamera2CameraControlImpl.isLowLightBoostOn()) {
+ if (completer != null) {
+ completer.setException(new IllegalStateException(
+ "Torch can not be enabled when low-light boost is on!"));
+ }
+ return;
+ }
+
mTargetTorchEnabled = enabled;
mCamera2CameraControlImpl.enableTorchInternal(enabled);
setLiveDataValue(mTorchState, enabled ? TorchState.ON : TorchState.OFF);
@@ -203,6 +211,23 @@
mEnableTorchCompleter = completer;
}
+ /**
+ * Force update the torch state to OFF.
+ *
+ * <p>This can be invoked when low-light boost is turned on. The torch state will also be
+ * updated as {@link TorchState#OFF}.
+ */
+ @ExecutedBy("mExecutor")
+ void forceUpdateTorchStateToOff() {
+ // Directly return if torch is originally off
+ if (!mTargetTorchEnabled) {
+ return;
+ }
+
+ mTargetTorchEnabled = false;
+ setLiveDataValue(mTorchState, TorchState.OFF);
+ }
+
private <T> void setLiveDataValue(@NonNull MutableLiveData<T> liveData, T value) {
if (Threads.isMainThread()) {
liveData.setValue(value);
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java
index 3228d2b..529c505 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CameraInfoImplTest.java
@@ -17,6 +17,7 @@
package androidx.camera.camera2.internal;
import static android.hardware.camera2.CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES;
+import static android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY;
import static android.hardware.camera2.CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_OFF;
import static android.hardware.camera2.CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_ON;
import static android.hardware.camera2.CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_PREVIEW_STABILIZATION;
@@ -62,6 +63,7 @@
import androidx.camera.core.DynamicRange;
import androidx.camera.core.ExposureState;
import androidx.camera.core.FocusMeteringAction;
+import androidx.camera.core.LowLightBoostState;
import androidx.camera.core.SurfaceOrientedMeteringPointFactory;
import androidx.camera.core.TorchState;
import androidx.camera.core.ZoomState;
@@ -163,6 +165,7 @@
private CameraManagerCompat mCameraManagerCompat;
private ZoomControl mMockZoomControl;
private TorchControl mMockTorchControl;
+ private LowLightBoostControl mMockLowLightBoostControl;
private ExposureControl mExposureControl;
private FocusMeteringControl mFocusMeteringControl;
private Camera2CameraControlImpl mMockCameraControl;
@@ -254,6 +257,27 @@
assertThat(cameraInfoInternal.hasFlashUnit()).isEqualTo(CAMERA1_FLASH_INFO_BOOLEAN);
}
+ @Config(minSdk = 35)
+ @Test
+ public void cameraInfo_canReturnLowLightBoostSupported_forBackCamera()
+ throws CameraAccessExceptionCompat {
+ init(/* hasAvailableCapabilities = */ false);
+
+ CameraInfoInternal cameraInfoInternal =
+ new Camera2CameraInfoImpl(CAMERA0_ID, mCameraManagerCompat);
+ assertThat(cameraInfoInternal.isLowLightBoostSupported()).isTrue();
+ }
+
+ @Test
+ public void cameraInfo_canReturnLowLightBoostSupported_forFrontCamera()
+ throws CameraAccessExceptionCompat {
+ init(/* hasAvailableCapabilities = */ false);
+
+ CameraInfoInternal cameraInfoInternal =
+ new Camera2CameraInfoImpl(CAMERA1_ID, mCameraManagerCompat);
+ assertThat(cameraInfoInternal.isLowLightBoostSupported()).isFalse();
+ }
+
@Test
public void cameraInfoWithoutCameraControl_canReturnDefaultTorchState()
throws CameraAccessExceptionCompat {
@@ -296,6 +320,46 @@
assertThat(camera2CameraInfoImpl.getTorchState().getValue()).isEqualTo(TorchState.ON);
}
+ @Config(minSdk = 35)
+ @Test
+ public void cameraInfoWithCameraControl_canReturnLowLightBoostState()
+ throws CameraAccessExceptionCompat {
+ init(/* hasAvailableCapabilities = */ false);
+
+ when(mMockLowLightBoostControl.getLowLightBoostState()).thenReturn(
+ new MutableLiveData<>(LowLightBoostState.ACTIVE));
+ Camera2CameraInfoImpl camera2CameraInfoImpl =
+ new Camera2CameraInfoImpl(CAMERA0_ID, mCameraManagerCompat);
+ camera2CameraInfoImpl.linkWithCameraControl(mMockCameraControl);
+ assertThat(camera2CameraInfoImpl.getLowLightBoostState().getValue()).isEqualTo(
+ LowLightBoostState.ACTIVE);
+ }
+
+ @Config(minSdk = 35)
+ @Test
+ public void lowLightBoostStateLiveData_SameInstanceBeforeAndAfterCameraControlLink()
+ throws CameraAccessExceptionCompat {
+ init(/* hasAvailableCapabilities = */ false);
+
+ Camera2CameraInfoImpl camera2CameraInfoImpl =
+ new Camera2CameraInfoImpl(CAMERA0_ID, mCameraManagerCompat);
+
+ // Calls getLowLightBoostState() to trigger RedirectableLiveData
+ LiveData<Integer> lowLightBoostStateLiveData =
+ camera2CameraInfoImpl.getLowLightBoostState();
+
+ when(mMockLowLightBoostControl.getLowLightBoostState()).thenReturn(
+ new MutableLiveData<>(LowLightBoostState.ACTIVE));
+ camera2CameraInfoImpl.linkWithCameraControl(mMockCameraControl);
+
+ // LowLightBoostState LiveData instances are the same before and after the
+ // linkWithCameraControl.
+ assertThat(camera2CameraInfoImpl.getLowLightBoostState()).isSameInstanceAs(
+ lowLightBoostStateLiveData);
+ assertThat(camera2CameraInfoImpl.getLowLightBoostState().getValue()).isEqualTo(
+ LowLightBoostState.ACTIVE);
+ }
+
// zoom related tests just ensure it uses ZoomControl to get the value
// Full tests are performed at ZoomControlDeviceTest / ZoomControlTest.
@Test
@@ -937,12 +1001,14 @@
mMockZoomControl = mock(ZoomControl.class);
mMockTorchControl = mock(TorchControl.class);
+ mMockLowLightBoostControl = mock(LowLightBoostControl.class);
mExposureControl = mock(ExposureControl.class);
mMockCameraControl = mock(Camera2CameraControlImpl.class);
mFocusMeteringControl = mock(FocusMeteringControl.class);
when(mMockCameraControl.getZoomControl()).thenReturn(mMockZoomControl);
when(mMockCameraControl.getTorchControl()).thenReturn(mMockTorchControl);
+ when(mMockCameraControl.getLowLightBoostControl()).thenReturn(mMockLowLightBoostControl);
when(mMockCameraControl.getExposureControl()).thenReturn(mExposureControl);
when(mMockCameraControl.getFocusMeteringControl()).thenReturn(mFocusMeteringControl);
}
@@ -1022,6 +1088,16 @@
});
}
+ // Adds low-light boost support for the back camera since API 35
+ if (Build.VERSION.SDK_INT >= 35) {
+ shadowCharacteristics0.set(
+ CameraCharacteristics.CONTROL_AE_AVAILABLE_MODES,
+ new int[]{
+ CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY
+ }
+ );
+ }
+
// Mock the request capability
if (hasAvailableCapabilities) {
shadowCharacteristics0.set(REQUEST_AVAILABLE_CAPABILITIES,
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CapturePipelineTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CapturePipelineTest.kt
index a9e2c9d..0a8d0c7 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CapturePipelineTest.kt
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/Camera2CapturePipelineTest.kt
@@ -23,13 +23,13 @@
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CameraMetadata
+import android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.CaptureResult
import android.hardware.camera2.TotalCaptureResult
import android.media.Image
import android.media.ImageWriter
import android.os.Build
-import android.os.Looper
import android.view.Surface
import androidx.annotation.RequiresApi
import androidx.camera.camera2.impl.Camera2ImplConfig
@@ -79,15 +79,14 @@
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertThrows
-import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.Mockito.mock
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.Shadows
+import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.internal.DoNotInstrument
import org.robolectric.shadow.api.Shadow
@@ -96,12 +95,12 @@
private const val CAMERA_ID_0 = "0"
-@RunWith(RobolectricTestRunner::class)
+@RunWith(ParameterizedRobolectricTestRunner::class)
@DoNotInstrument
@Config(
minSdk = Build.VERSION_CODES.LOLLIPOP,
)
-class Camera2CapturePipelineTest {
+class Camera2CapturePipelineTest(private val isLowLightBoostEnabled: Boolean) {
private val context = ApplicationProvider.getApplicationContext() as Context
private val executorService = Executors.newSingleThreadScheduledExecutor()
@@ -151,8 +150,17 @@
private lateinit var testScreenFlash: MockScreenFlash
+ companion object {
+ @JvmStatic
+ @ParameterizedRobolectricTestRunner.Parameters(name = "isLowLightBoostEnabled: {0}")
+ fun data() = listOf(true, false)
+ }
+
@Before
fun setUp() {
+ if (isLowLightBoostEnabled) {
+ assumeTrue(Build.VERSION.SDK_INT >= 35)
+ }
initCameras()
testScreenFlash = MockScreenFlash()
}
@@ -212,8 +220,8 @@
)
// Assert.
- assertTrue(fakeTask.preCaptureCountDown.await(3, TimeUnit.SECONDS))
- assertTrue(fakeTask.postCaptureCountDown.await(3, TimeUnit.SECONDS))
+ assertThat(fakeTask.preCaptureCountDown.await(3, TimeUnit.SECONDS)).isTrue()
+ assertThat(fakeTask.postCaptureCountDown.await(3, TimeUnit.SECONDS)).isTrue()
}
@Test
@@ -288,12 +296,19 @@
simulateRepeatingResult(initialDelay = 100)
}
- // Assert 1, verify the CONTROL_AE_PRECAPTURE_TRIGGER is triggered
+ // Assert 1, verify the CONTROL_AE_PRECAPTURE_TRIGGER is triggered when low-light boost is
+ // off, otherwise, is not triggered.
immediateCompleteCapture.verifyRequestResult {
it.requestContains(
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START
- )
+ ) != isLowLightBoostEnabled
+ }
+
+ // When low-light boost is on, AE pre-capture is not triggered. Therefore, converged stage
+ // is not required.
+ if (isLowLightBoostEnabled) {
+ return
}
// Switch the repeating result to 3A converged state.
@@ -302,7 +317,8 @@
resultParameters = resultConverged
)
- // Assert 2 that CONTROL_AE_PRECAPTURE_TRIGGER should be cancelled finally.
+ // Assert 2 that CONTROL_AE_PRECAPTURE_TRIGGER should be cancelled finally when low-light
+ // boost is off, otherwise, TRIGGER_CANCEL is not triggered.
if (Build.VERSION.SDK_INT >= 23) {
immediateCompleteCapture.verifyRequestResult {
it.requestContains(
@@ -345,12 +361,19 @@
)
}
- // Assert 1, verify the CONTROL_AE_PRECAPTURE_TRIGGER is triggered
+ // Assert 1, verify the CONTROL_AE_PRECAPTURE_TRIGGER is triggered when low-light boost is
+ // off, otherwise, is not triggered.
immediateCompleteCapture.verifyRequestResult {
it.requestContains(
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START
- )
+ ) != isLowLightBoostEnabled
+ }
+
+ // When low-light boost is on, AE pre-capture is not triggered. Therefore, converged stage
+ // is not required.
+ if (isLowLightBoostEnabled) {
+ return
}
// Switch the repeating result to 3A converged state.
@@ -359,7 +382,8 @@
resultParameters = resultConverged
)
- // Assert 2 that CONTROL_AE_PRECAPTURE_TRIGGER should be cancelled finally.
+ // Assert 2 that CONTROL_AE_PRECAPTURE_TRIGGER should be cancelled finally when low-light
+ // boost is off, otherwise, TRIGGER_CANCEL is not triggered.
if (Build.VERSION.SDK_INT >= 23) {
immediateCompleteCapture.verifyRequestResult {
it.requestContains(
@@ -430,7 +454,19 @@
}
// Assert 1 torch should be turned on
- cameraControl.waitForSessionConfig { it.isTorchParameterEnabled() }
+ cameraControl.waitForSessionConfig {
+ if (isLowLightBoostEnabled) {
+ it.isLowLightBoostEnabled()
+ } else {
+ it.isTorchParameterEnabled()
+ }
+ }
+
+ // When low-light boost is on, AE pre-capture is not triggered. Therefore, converged stage
+ // is not required.
+ if (isLowLightBoostEnabled) {
+ return
+ }
// Switch the repeating result to 3A converged state.
cameraControl.simulateRepeatingResult(
@@ -468,7 +504,19 @@
}
// Assert 1 torch should be turned on
- cameraControl.waitForSessionConfig { it.isTorchParameterEnabled() }
+ cameraControl.waitForSessionConfig {
+ if (isLowLightBoostEnabled) {
+ it.isLowLightBoostEnabled()
+ } else {
+ it.isTorchParameterEnabled()
+ }
+ }
+
+ // When low-light boost is on, AE pre-capture is not triggered. Therefore, converged stage
+ // is not required.
+ if (isLowLightBoostEnabled) {
+ return
+ }
// Switch the repeating result to 3A converged state.
cameraControl.simulateRepeatingResult(
@@ -503,7 +551,19 @@
}
// Assert 1 torch should be turned on
- cameraControl.waitForSessionConfig { it.isTorchParameterEnabled() }
+ cameraControl.waitForSessionConfig {
+ if (isLowLightBoostEnabled) {
+ it.isLowLightBoostEnabled()
+ } else {
+ it.isTorchParameterEnabled()
+ }
+ }
+
+ // When low-light boost is on, AE pre-capture is not triggered. Therefore, converged stage
+ // is not required.
+ if (isLowLightBoostEnabled) {
+ return
+ }
// Switch the repeating result to 3A converged state.
cameraControl.simulateRepeatingResult(
@@ -794,7 +854,7 @@
// Assert.
val exception =
assertThrows(ExecutionException::class.java) { future.get(1, TimeUnit.SECONDS) }
- assertTrue(exception.cause is ImageCaptureException)
+ assertThat(exception.cause).isInstanceOf(ImageCaptureException::class.java)
assertThat((exception.cause as ImageCaptureException).imageCaptureError)
.isEqualTo(ImageCapture.ERROR_CAPTURE_FAILED)
}
@@ -830,7 +890,7 @@
// Assert.
val exception =
assertThrows(ExecutionException::class.java) { future.get(1, TimeUnit.SECONDS) }
- assertTrue(exception.cause is ImageCaptureException)
+ assertThat(exception.cause).isInstanceOf(ImageCaptureException::class.java)
assertThat((exception.cause as ImageCaptureException).imageCaptureError)
.isEqualTo(ImageCapture.ERROR_CAMERA_CLOSED)
}
@@ -1170,6 +1230,13 @@
CameraMetadata.FLASH_MODE_TORCH
}
+ private fun SessionConfig.isLowLightBoostEnabled(): Boolean {
+ val config = toCamera2Config()
+
+ return config.getCaptureRequestOption(CaptureRequest.CONTROL_AE_MODE, null) ==
+ CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY
+ }
+
private fun List<CaptureConfig>.isTorchParameterDisabled() =
requestContains(
CaptureRequest.CONTROL_AE_MODE,
@@ -1283,6 +1350,8 @@
setActive(true)
incrementUseCount()
this.screenFlash = testScreenFlash
+ // Applies low-light boost setting
+ enableLowLightBoostAndAssert(this)
}
}
@@ -1297,14 +1366,19 @@
set(CameraCharacteristics.FLASH_INFO_AVAILABLE, true)
set(
CameraCharacteristics.CONTROL_AE_AVAILABLE_MODES,
- intArrayOf(
- CaptureRequest.CONTROL_AE_MODE_OFF,
- CaptureRequest.CONTROL_AE_MODE_ON,
- CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH,
- CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH,
- CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH_REDEYE,
- CaptureRequest.CONTROL_AE_MODE_ON_EXTERNAL_FLASH
- )
+ mutableListOf<Int>()
+ .apply {
+ add(CaptureRequest.CONTROL_AE_MODE_OFF)
+ add(CaptureRequest.CONTROL_AE_MODE_ON)
+ add(CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH)
+ add(CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH)
+ add(CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH_REDEYE)
+ add(CaptureRequest.CONTROL_AE_MODE_ON_EXTERNAL_FLASH)
+ if (Build.VERSION.SDK_INT >= 35) {
+ add(CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY)
+ }
+ }
+ .toIntArray()
)
set(CameraCharacteristics.LENS_FACING, CameraMetadata.LENS_FACING_BACK)
}
@@ -1334,7 +1408,7 @@
}
waitingList.add(resultPair)
}
- assertTrue(resultPair.first.await(timeout, TimeUnit.MILLISECONDS))
+ assertThat(resultPair.first.await(timeout, TimeUnit.MILLISECONDS)).isTrue()
waitingList.remove(resultPair)
}
@@ -1459,13 +1533,16 @@
cameraControl.mZslControl = zslControl
+ // Applies low-light boost setting
+ enableLowLightBoostAndAssert(cameraControl)
+
return cameraControl
}
- private fun Looper.advanceUntilIdle() {
- val shadowLooper = Shadows.shadowOf(this)
- while (!shadowLooper.isIdle) {
- shadowLooper.idle()
+ private fun enableLowLightBoostAndAssert(cameraControlImpl: Camera2CameraControlImpl) {
+ if (isLowLightBoostEnabled) {
+ executorService.run { cameraControlImpl.enableLowLightBoostInternal(true) }
}
+ assertThat(cameraControlImpl.isLowLightBoostOn).isEqualTo(isLowLightBoostEnabled)
}
}
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/LowLightBoostControlTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/LowLightBoostControlTest.kt
new file mode 100644
index 0000000..bed297c
--- /dev/null
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/LowLightBoostControlTest.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.internal
+
+import android.content.Context
+import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.CameraManager
+import android.hardware.camera2.CameraMetadata
+import android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY
+import android.hardware.camera2.CaptureRequest
+import android.hardware.camera2.CaptureResult.CONTROL_LOW_LIGHT_BOOST_STATE
+import android.hardware.camera2.TotalCaptureResult
+import android.os.Looper.getMainLooper
+import androidx.camera.camera2.internal.compat.CameraCharacteristicsCompat
+import androidx.camera.core.CameraControl.OperationCanceledException
+import androidx.camera.core.LowLightBoostState
+import androidx.camera.core.impl.CameraControlInternal
+import androidx.camera.core.impl.utils.executor.CameraXExecutors
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import com.google.common.util.concurrent.ListenableFuture
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito
+import org.mockito.Mockito.mock
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.internal.DoNotInstrument
+import org.robolectric.shadow.api.Shadow
+import org.robolectric.shadows.ShadowCameraCharacteristics
+import org.robolectric.shadows.ShadowCameraManager
+import org.robolectric.shadows.ShadowLog
+
+private const val CAMERA_ID_0 = "0"
+
+@RunWith(RobolectricTestRunner::class)
+@DoNotInstrument
+@Config(
+ minSdk = 35,
+)
+class LowLightBoostControlTest {
+
+ private val context = ApplicationProvider.getApplicationContext() as Context
+
+ @Test
+ fun enableLowLightBoostThrowException_stateActive_whenIsSupported() {
+ ShadowLog.stream = System.out
+ initCamera(supportedLlb = true)
+ createLowLightBoostControl().apply {
+ setActive(true)
+ val future = enableLowLightBoost(true)
+
+ // Issue CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY AE mode capture result
+ // to check whether the future can be completed or not.
+ issueControlAeModeCaptureResult(mCaptureResultListener)
+ shadowOf(getMainLooper()).idle()
+
+ future.get(1, TimeUnit.SECONDS)
+ }
+ }
+
+ @Test
+ fun enableLowLightBoost_stateInactive_whenIsSupported() {
+ initCamera(supportedLlb = true)
+ createLowLightBoostControl().apply {
+ setActive(false)
+ val future = enableLowLightBoost(true)
+ shadowOf(getMainLooper()).idle()
+ assertFutureCompleteWithException(future, OperationCanceledException::class.java)
+ }
+ }
+
+ @Test
+ fun enableLowLightBoostThrowException_stateActive_whenIsNotSupported() {
+ initCamera(supportedLlb = false)
+ createLowLightBoostControl().apply {
+ setActive(true)
+ val future = enableLowLightBoost(true)
+ assertFutureCompleteWithException(future, IllegalStateException::class.java)
+ }
+ }
+
+ @Test
+ fun getLowLightBoostState_stateActive_whenIsNotSupported() {
+ initCamera(supportedLlb = false)
+ createLowLightBoostControl().apply {
+ setActive(true)
+ enableLowLightBoost(true)
+ shadowOf(getMainLooper()).idle()
+ assertThat(lowLightBoostState.value).isEqualTo(LowLightBoostState.OFF)
+ }
+ }
+
+ @Test
+ fun getLowLightBoostState_stateActive_whenIsSupported() {
+ initCamera(supportedLlb = true)
+ createLowLightBoostControl().apply {
+ setActive(true)
+ // State is OFF before low-light boost is enabled
+ assertThat(lowLightBoostState.value).isEqualTo(LowLightBoostState.OFF)
+
+ enableLowLightBoost(true)
+ shadowOf(getMainLooper()).idle()
+ // State is INACTIVE after low-light boost is enabled but no ACTIVE state is received
+ assertThat(lowLightBoostState.value).isEqualTo(LowLightBoostState.INACTIVE)
+
+ issueControlAeModeCaptureResult(
+ mCaptureResultListener,
+ llbState = LowLightBoostState.ACTIVE
+ )
+ // When low-light boost state is updated, postValue method is invoked. In robolectric
+ // test, it is hard to know when will the task is added to the main thread. Adding
+ // delay 100ms here to make sure that the value update task has been posted to the main
+ // thread.
+ runBlocking { delay(100) }
+ shadowOf(getMainLooper()).idle()
+ // State is ACTIVE after ACTIVE state is received
+ assertThat(lowLightBoostState.value).isEqualTo(LowLightBoostState.ACTIVE)
+
+ issueControlAeModeCaptureResult(
+ mCaptureResultListener,
+ llbState = LowLightBoostState.INACTIVE
+ )
+ runBlocking { delay(100) }
+ shadowOf(getMainLooper()).idle()
+ // State is INACTIVE after INACTIVE state is received again
+ assertThat(lowLightBoostState.value).isEqualTo(LowLightBoostState.INACTIVE)
+ }
+ }
+
+ @Test
+ fun getLowLightBoostState_stateInactive_whenIsSupported() {
+ initCamera(supportedLlb = true)
+ createLowLightBoostControl().apply {
+ setActive(false)
+ assertThat(lowLightBoostState.value).isEqualTo(LowLightBoostState.OFF)
+ }
+ }
+
+ @Test
+ fun enableTorchTwice_cancelPreviousFuture() {
+ initCamera(supportedLlb = true)
+ createLowLightBoostControl().apply {
+ setActive(true)
+ val future1 = enableLowLightBoost(true)
+ val future2 = enableLowLightBoost(true)
+ shadowOf(getMainLooper()).idle()
+
+ // Verifies that the first future has been canceled.
+ assertFutureCompleteWithException(future1, OperationCanceledException::class.java)
+
+ // Verifies that the second future can be completed after issue the AE mode capture
+ // result
+ issueControlAeModeCaptureResult(mCaptureResultListener)
+ shadowOf(getMainLooper()).idle()
+ future2.get(1, TimeUnit.SECONDS)
+ }
+ }
+
+ @Test
+ fun setInActive_cancelPreviousFuture() {
+ initCamera(supportedLlb = true)
+ createLowLightBoostControl().apply {
+ setActive(true)
+ val future = enableLowLightBoost(true)
+ shadowOf(getMainLooper()).idle()
+
+ setActive(false)
+ assertFutureCompleteWithException(future, OperationCanceledException::class.java)
+ }
+ }
+
+ @Test
+ fun setInActive_changeToStateOff() {
+ initCamera(supportedLlb = true)
+ createLowLightBoostControl().apply {
+ setActive(true)
+ enableLowLightBoost(true)
+ assertThat(lowLightBoostState.value).isEqualTo(LowLightBoostState.INACTIVE)
+
+ setActive(false)
+ shadowOf(getMainLooper()).idle()
+ assertThat(lowLightBoostState.value).isEqualTo(LowLightBoostState.OFF)
+ }
+ }
+
+ private fun assertFutureCompleteWithException(future: ListenableFuture<Void>, clazz: Class<*>) {
+ assertThat(future.isDone).isTrue()
+ try {
+ future.get()
+ } catch (exception: ExecutionException) {
+ assertThat(exception.cause).isInstanceOf(clazz)
+ }
+ }
+
+ private fun createLowLightBoostControl(): LowLightBoostControl {
+ val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
+ val cameraCharacteristics = cameraManager.getCameraCharacteristics(CAMERA_ID_0)
+ val characteristicsCompat =
+ CameraCharacteristicsCompat.toCameraCharacteristicsCompat(
+ cameraCharacteristics,
+ CAMERA_ID_0
+ )
+
+ val cameraControlImpl =
+ Camera2CameraControlImpl(
+ characteristicsCompat,
+ CameraXExecutors.mainThreadExecutor(),
+ CameraXExecutors.mainThreadExecutor(),
+ mock(CameraControlInternal.ControlUpdateCallback::class.java)
+ )
+
+ return LowLightBoostControl(
+ cameraControlImpl,
+ characteristicsCompat,
+ CameraXExecutors.mainThreadExecutor()
+ )
+ }
+
+ private fun issueControlAeModeCaptureResult(
+ captureResultListener: Camera2CameraControlImpl.CaptureResultListener,
+ resultAeMode: Int = CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY,
+ llbState: Int? = null
+ ) {
+ Executors.newSingleThreadScheduledExecutor()
+ .schedule(
+ {
+ val captureRequest = mock(CaptureRequest::class.java)
+ Mockito.`when`(captureRequest.get(CaptureRequest.CONTROL_AE_MODE))
+ .thenReturn(resultAeMode)
+
+ val totalCaptureResult = mock(TotalCaptureResult::class.java)
+ Mockito.`when`(totalCaptureResult.request).thenReturn(captureRequest)
+
+ llbState?.let {
+ Mockito.`when`(totalCaptureResult.get(CONTROL_LOW_LIGHT_BOOST_STATE))
+ .thenReturn(llbState)
+ }
+ captureResultListener.onCaptureResult(totalCaptureResult)
+ },
+ 20,
+ TimeUnit.MILLISECONDS
+ )
+ }
+
+ private fun initCamera(supportedLlb: Boolean) {
+ val cameraCharacteristics =
+ ShadowCameraCharacteristics.newCameraCharacteristics().also {
+ Shadow.extract<ShadowCameraCharacteristics>(it).apply {
+ set(
+ CameraCharacteristics.CONTROL_AE_AVAILABLE_MODES,
+ mutableListOf<Int>()
+ .apply {
+ add(CaptureRequest.CONTROL_AE_MODE_OFF)
+ add(CaptureRequest.CONTROL_AE_MODE_ON)
+ add(CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH)
+ add(CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH)
+ add(CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH_REDEYE)
+ add(CaptureRequest.CONTROL_AE_MODE_ON_EXTERNAL_FLASH)
+
+ if (supportedLlb) {
+ add(CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY)
+ }
+ }
+ .toIntArray()
+ )
+ set(CameraCharacteristics.LENS_FACING, CameraMetadata.LENS_FACING_BACK)
+ }
+ }
+
+ Shadow.extract<ShadowCameraManager>(context.getSystemService(Context.CAMERA_SERVICE))
+ .apply { addCamera(CAMERA_ID_0, cameraCharacteristics) }
+ }
+}
diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/TorchControlTest.java b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/TorchControlTest.java
index 4937e7b..d725f7f 100644
--- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/TorchControlTest.java
+++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/internal/TorchControlTest.java
@@ -16,6 +16,7 @@
package androidx.camera.camera2.internal;
+import static android.hardware.camera2.CameraMetadata.CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY;
import static android.os.Looper.getMainLooper;
import static com.google.common.truth.Truth.assertThat;
@@ -29,6 +30,7 @@
import static org.mockito.internal.verification.VerificationModeFactory.times;
import static org.robolectric.Shadows.shadowOf;
+import android.annotation.SuppressLint;
import android.content.Context;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCharacteristics;
@@ -75,6 +77,8 @@
private TorchControl mNoFlashUnitTorchControl;
private TorchControl mTorchControl;
+ private CameraCharacteristicsCompat mLlbEnabledCameraCharacteristicsCompat;
+ private Camera2CameraControlImpl mLlbEnabledCamera2CameraControl;
private Camera2CameraControlImpl.CaptureResultListener mCaptureResultListener;
private TestLifecycleOwner mLifecycleOwner;
@@ -104,22 +108,25 @@
/* Prepare CameraControl 1 which flash is available */
CameraCharacteristics cameraCharacteristics1 =
cameraManager.getCameraCharacteristics(CAMERA1_ID);
- CameraCharacteristicsCompat characteristicsCompat1 =
+ mLlbEnabledCameraCharacteristicsCompat =
CameraCharacteristicsCompat.toCameraCharacteristicsCompat(cameraCharacteristics1,
CAMERA1_ID);
- Camera2CameraControlImpl camera2CameraControlImpl1 =
- spy(new Camera2CameraControlImpl(characteristicsCompat1,
- CameraXExecutors.mainThreadExecutor(),
- CameraXExecutors.mainThreadExecutor(),
- mock(CameraControlInternal.ControlUpdateCallback.class)));
- mTorchControl = new TorchControl(camera2CameraControlImpl1, characteristicsCompat1,
+ mLlbEnabledCamera2CameraControl = new Camera2CameraControlImpl(
+ mLlbEnabledCameraCharacteristicsCompat,
+ CameraXExecutors.mainThreadExecutor(),
+ CameraXExecutors.mainThreadExecutor(),
+ mock(CameraControlInternal.ControlUpdateCallback.class));
+ Camera2CameraControlImpl camera2CameraControl1 = spy(mLlbEnabledCamera2CameraControl);
+ mTorchControl = new TorchControl(camera2CameraControl1,
+ mLlbEnabledCameraCharacteristicsCompat,
CameraXExecutors.mainThreadExecutor());
mTorchControl.setActive(true);
ArgumentCaptor<Camera2CameraControlImpl.CaptureResultListener> argumentCaptor =
ArgumentCaptor.forClass(Camera2CameraControlImpl.CaptureResultListener.class);
- verify(camera2CameraControlImpl1).addCaptureResultListener(argumentCaptor.capture());
+ verify(camera2CameraControl1).addCaptureResultListener(
+ argumentCaptor.capture());
mCaptureResultListener = argumentCaptor.getValue();
/* Prepare Lifecycle for test LiveData */
@@ -305,6 +312,60 @@
assertThat(torchStates.get(2)).isEqualTo(TorchState.OFF); // by enableTorch(false)
}
+ @Config(minSdk = 35)
+ @Test
+ public void enableTorchIsCanceled_whenLowLightBoostIsOn() {
+ // Activates the Camera2CameraControlImpl and ensures low-light boost is turned on
+ mLlbEnabledCamera2CameraControl.incrementUseCount();
+ mLlbEnabledCamera2CameraControl.setActive(true);
+
+ TorchControl torchControl = mLlbEnabledCamera2CameraControl.getTorchControl();
+
+ mLlbEnabledCamera2CameraControl.enableLowLightBoostAsync(true);
+ shadowOf(getMainLooper()).idle();
+ assertThat(mLlbEnabledCamera2CameraControl.isLowLightBoostOn()).isTrue();
+
+ // Verifies that enabling torch operation will run failed with IllegalStateException cause
+ ListenableFuture<Void> future = torchControl.enableTorch(true);
+ shadowOf(getMainLooper()).idle();
+ Throwable cause = null;
+ try {
+ future.get();
+ } catch (ExecutionException | InterruptedException e) {
+ // The real cause is wrapped in ExecutionException, retrieve it and check.
+ cause = e.getCause();
+ }
+
+ assertThat(cause).isInstanceOf(IllegalStateException.class);
+ }
+
+ @SuppressLint("BanThreadSleep")
+ @Config(minSdk = 35)
+ @Test
+ public void torchIsDisabled_whenLowLightBoostIsTurnedOn() {
+ // Activates the Camera2CameraControlImpl
+ mLlbEnabledCamera2CameraControl.incrementUseCount();
+ mLlbEnabledCamera2CameraControl.setActive(true);
+
+ TorchControl torchControl = mLlbEnabledCamera2CameraControl.getTorchControl();
+ assertThat(torchControl.getTorchState().getValue()).isEqualTo(TorchState.OFF);
+
+ // Turns on torch
+ torchControl.enableTorch(true);
+ shadowOf(getMainLooper()).idle();
+ assertThat(mLlbEnabledCamera2CameraControl.isTorchOn()).isTrue();
+ assertThat(torchControl.getTorchState().getValue()).isEqualTo(TorchState.ON);
+
+ // Turns on low-light boost
+ mLlbEnabledCamera2CameraControl.enableLowLightBoostAsync(true);
+ shadowOf(getMainLooper()).idle();
+
+ // Verifies that enabling torch operation will run failed with IllegalStateException cause
+ assertThat(mLlbEnabledCamera2CameraControl.isLowLightBoostOn()).isTrue();
+ assertThat(mLlbEnabledCamera2CameraControl.isTorchOn()).isFalse();
+ assertThat(torchControl.getTorchState().getValue()).isEqualTo(TorchState.OFF);
+ }
+
private void initShadowCameraManager() {
// **** Camera 0 characteristics ****//
CameraCharacteristics characteristics0 =
@@ -326,6 +387,11 @@
ShadowCameraCharacteristics shadowCharacteristics1 = Shadow.extract(characteristics1);
shadowCharacteristics1.set(CameraCharacteristics.FLASH_INFO_AVAILABLE, true);
+ if (Build.VERSION.SDK_INT >= 35) {
+ // Adds low-light boost capability for related tests
+ shadowCharacteristics1.set(CameraCharacteristics.CONTROL_AE_AVAILABLE_MODES,
+ new int[]{CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY});
+ }
((ShadowCameraManager)
Shadow.extract(
diff --git a/camera/camera-core/build.gradle b/camera/camera-core/build.gradle
index 191b670..b00dd05 100644
--- a/camera/camera-core/build.gradle
+++ b/camera/camera-core/build.gradle
@@ -85,6 +85,8 @@
}
android {
+ compileSdk 35
+
defaultConfig {
externalNativeBuild {
def versionScript = file("src/main/cpp/jni.lds").getAbsolutePath()
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraControl.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraControl.java
index beff098..9dbfa50 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraControl.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraControl.java
@@ -18,6 +18,7 @@
import androidx.annotation.FloatRange;
import androidx.annotation.RestrictTo;
+import androidx.camera.core.impl.utils.futures.Futures;
import com.google.common.util.concurrent.ListenableFuture;
@@ -63,6 +64,53 @@
@NonNull ListenableFuture<Void> enableTorch(boolean torch);
/**
+ * Enables low-light boost mode.
+ *
+ * <p>Devices running Android 15 or higher can provide support for low-light boost. This
+ * feature can automatically adjust the brightness of the preview, video or image analysis
+ * streams in low-light conditions. This is different from how the night mode camera
+ * extension creates still images, because night mode combines a burst of photos to create a
+ * single, enhanced image. While night mode works very well for creating a still image, it
+ * can't create a continuous stream of frames, but Low Light Boost can. Thus, Low Light Boost
+ * enables new camera capabilities, such as the following:
+ *
+ * <ul>
+ * <li>Providing an enhanced image preview, so users can better frame their low-light pictures.
+ * <li>Recording brighter videos in low-light conditions.
+ * <li>Scanning QR codes in low-light conditions.
+ * </ul>
+ *
+ * <p>Applications can query the low-light boost availability via
+ * {@link CameraInfo#isLowLightBoostSupported()}. If you enable Low Light Boost, it
+ * automatically turns on when there's a low light level, and turns off when there's more light.
+ *
+ * <p>If the camera device does not support low-light boost, the value obtained via
+ * {@link CameraInfo#getLowLightBoostState()} will always be {@link LowLightBoostState#OFF}.
+ *
+ * <p>Note that this mode may interact with other configurations:
+ *
+ * <ul>
+ * <li>When low-light boost is on, the flash or torch functionality may be unavailable.
+ * <li>When frame rate configuration results in an FPS exceeding 30, low-light boost will be
+ * disabled and the state will always be ({@link LowLightBoostState#OFF}).
+ * </ul>
+ *
+ * <p>Therefore, to use flash or torch functionality, low-light boost mode must be disabled.
+ * To ensure low-light boost mode functions correctly, avoid frame rate settings that result
+ * in an FPS exceeding 30.
+ *
+ * @param lowLightBoost true to turn on the low-light boost mode, false to turn it off.
+ * @return A {@link ListenableFuture} which is successful when the low-light boost mode was
+ * changed to the value specified. It fails when it is unable to change the log-light boost
+ * state. Cancellation of this future is a no-op.
+ * @see CameraInfo#isLowLightBoostSupported()
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ default @NonNull ListenableFuture<Void> enableLowLightBoostAsync(boolean lowLightBoost) {
+ return Futures.immediateFailedFuture(new OperationCanceledException("Not supported!"));
+ }
+
+ /**
* Starts a focus and metering action configured by the {@link FocusMeteringAction}.
*
* <p>It will trigger an auto focus action and enable AF/AE/AWB metering regions. The action
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
index 805440f..e2f6556 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/CameraInfo.java
@@ -17,6 +17,7 @@
package androidx.camera.core;
import android.graphics.ImageFormat;
+import android.hardware.camera2.CaptureRequest;
import android.media.MediaActionSound;
import android.util.Range;
import android.view.Surface;
@@ -30,6 +31,7 @@
import androidx.camera.core.internal.compat.MediaActionSoundCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import org.jspecify.annotations.NonNull;
@@ -430,4 +432,38 @@
@RestrictTo(Scope.LIBRARY_GROUP)
@interface ImplementationType {
}
+
+ /**
+ * Returns if low-light boost is supported on the device. Low-light boost can be turned on via
+ * {@link CameraControl#enableLowLightBoostAsync(boolean)}.
+ *
+ * @return true if
+ * {@link CaptureRequest#CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY} is supported,
+ * otherwise false.
+ * @see CameraControl#enableLowLightBoostAsync(boolean)
+ * @see CaptureRequest#CONTROL_AE_MODE_ON_LOW_LIGHT_BOOST_BRIGHTNESS_PRIORITY
+ */
+ @RestrictTo(Scope.LIBRARY_GROUP)
+ default boolean isLowLightBoostSupported() {
+ return false;
+ }
+
+ /**
+ * Returns a {@link LiveData} of current {@link LowLightBoostState}.
+ *
+ * <p>Low-light boost can be turned on via
+ * {@link CameraControl#enableLowLightBoostAsync(boolean)} which will trigger the change
+ * event to the returned {@link LiveData}. Apps can either get immediate value via
+ * {@link LiveData#getValue()} or observe it via
+ * {@link LiveData#observe(LifecycleOwner, Observer)} to update low-light boost UI accordingly.
+ *
+ * <p>If the camera doesn't support low-light boost, then the state will always be
+ * {@link LowLightBoostState#OFF}.
+ *
+ * @return a {@link LiveData} containing current low-light boost state.
+ */
+ @RestrictTo(Scope.LIBRARY_GROUP)
+ default @NonNull LiveData<Integer> getLowLightBoostState() {
+ return new MutableLiveData<>(LowLightBoostState.OFF);
+ }
}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/LowLightBoostState.java b/camera/camera-core/src/main/java/androidx/camera/core/LowLightBoostState.java
new file mode 100644
index 0000000..94d37b3
--- /dev/null
+++ b/camera/camera-core/src/main/java/androidx/camera/core/LowLightBoostState.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.core;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.RestrictTo;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Defines the valid states for Low Light Boost, as returned by
+ * {@link CameraInfo#getLowLightBoostState()}.
+ *
+ * <p>These states indicate whether the camera device supports low light boost and if it is
+ * currently active.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class LowLightBoostState {
+ /** Low-light boost is off. */
+ public static final int OFF = -1;
+ /** Low-light boost is on but inactive. */
+ public static final int INACTIVE = 0;
+ /** Low-light boost is on and active. */
+ public static final int ACTIVE = 1;
+
+ private LowLightBoostState() {
+ }
+
+ /**
+ */
+ @IntDef({OFF, INACTIVE, ACTIVE})
+ @Retention(RetentionPolicy.SOURCE)
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ public @interface State {
+ }
+}
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraControl.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraControl.java
index f3531bb..66ea978 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraControl.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraControl.java
@@ -52,6 +52,11 @@
}
@Override
+ public @NonNull ListenableFuture<Void> enableLowLightBoostAsync(boolean lowLightBoost) {
+ return mCameraControlInternal.enableLowLightBoostAsync(lowLightBoost);
+ }
+
+ @Override
public @NonNull ListenableFuture<FocusMeteringResult> startFocusAndMetering(
@NonNull FocusMeteringAction action) {
return mCameraControlInternal.startFocusAndMetering(action);
diff --git a/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java b/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java
index 267475d..fd752fd 100644
--- a/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java
+++ b/camera/camera-core/src/main/java/androidx/camera/core/impl/ForwardingCameraInfo.java
@@ -73,6 +73,16 @@
}
@Override
+ public boolean isLowLightBoostSupported() {
+ return mCameraInfoInternal.isLowLightBoostSupported();
+ }
+
+ @Override
+ public @NonNull LiveData<Integer> getLowLightBoostState() {
+ return mCameraInfoInternal.getLowLightBoostState();
+ }
+
+ @Override
public @NonNull LiveData<ZoomState> getZoomState() {
return mCameraInfoInternal.getZoomState();
}
diff --git a/camera/camera-effects/build.gradle b/camera/camera-effects/build.gradle
index 647f2fd..8bb2c7e 100644
--- a/camera/camera-effects/build.gradle
+++ b/camera/camera-effects/build.gradle
@@ -49,6 +49,7 @@
androidTestImplementation("androidx.concurrent:concurrent-futures-ktx:1.1.0")
}
android {
+ compileSdk 35
testOptions.unitTests.includeAndroidResources = true
namespace = "androidx.camera.effects"
}
diff --git a/camera/camera-extensions/build.gradle b/camera/camera-extensions/build.gradle
index 0e4ace09..bb99409 100644
--- a/camera/camera-extensions/build.gradle
+++ b/camera/camera-extensions/build.gradle
@@ -76,6 +76,8 @@
}
android {
+ compileSdk 35
+
buildTypes.configureEach {
consumerProguardFiles "proguard-rules.pro"
}
diff --git a/camera/camera-lifecycle/build.gradle b/camera/camera-lifecycle/build.gradle
index c02d39a..a051ed1 100644
--- a/camera/camera-lifecycle/build.gradle
+++ b/camera/camera-lifecycle/build.gradle
@@ -61,6 +61,8 @@
}
android {
+ compileSdk 35
+
lintOptions {
enable 'CameraXQuirksClassDetector'
}
diff --git a/camera/camera-lifecycle/samples/build.gradle b/camera/camera-lifecycle/samples/build.gradle
index 5f5011f..09838ff 100644
--- a/camera/camera-lifecycle/samples/build.gradle
+++ b/camera/camera-lifecycle/samples/build.gradle
@@ -39,6 +39,7 @@
}
android {
+ compileSdk 35
namespace = "androidx.camera.lifecycle.samples"
}
diff --git a/camera/camera-mlkit-vision/build.gradle b/camera/camera-mlkit-vision/build.gradle
index 6ad87b4..25831a7 100644
--- a/camera/camera-mlkit-vision/build.gradle
+++ b/camera/camera-mlkit-vision/build.gradle
@@ -45,6 +45,8 @@
}
android {
+ compileSdk 35
+
lintOptions {
enable 'CameraXQuirksClassDetector'
}
diff --git a/camera/camera-testing/build.gradle b/camera/camera-testing/build.gradle
index 0038f04..a702c14 100644
--- a/camera/camera-testing/build.gradle
+++ b/camera/camera-testing/build.gradle
@@ -79,6 +79,8 @@
}
android {
+ compileSdk 35
+
defaultConfig {
externalNativeBuild {
cmake {
diff --git a/camera/camera-testlib-extensions/build.gradle b/camera/camera-testlib-extensions/build.gradle
index d0e83eeb..a220c59 100644
--- a/camera/camera-testlib-extensions/build.gradle
+++ b/camera/camera-testlib-extensions/build.gradle
@@ -35,6 +35,8 @@
}
android {
+ compileSdk 35
+
lintOptions {
enable 'CameraXQuirksClassDetector'
}
diff --git a/camera/camera-video/build.gradle b/camera/camera-video/build.gradle
index d719316..9cbe32e 100644
--- a/camera/camera-video/build.gradle
+++ b/camera/camera-video/build.gradle
@@ -75,6 +75,8 @@
}
android {
+ compileSdk 35
+
lintOptions {
enable 'CameraXQuirksClassDetector'
}
diff --git a/camera/camera-view/build.gradle b/camera/camera-view/build.gradle
index e1601f3..1d1f1e5 100644
--- a/camera/camera-view/build.gradle
+++ b/camera/camera-view/build.gradle
@@ -76,6 +76,8 @@
androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0")
}
android {
+ compileSdk 35
+
lintOptions {
enable 'CameraXQuirksClassDetector'
}
diff --git a/camera/integration-tests/camerapipetestapp/build.gradle b/camera/integration-tests/camerapipetestapp/build.gradle
index a615ba4..6c49dbe 100644
--- a/camera/integration-tests/camerapipetestapp/build.gradle
+++ b/camera/integration-tests/camerapipetestapp/build.gradle
@@ -22,6 +22,8 @@
}
android {
+ compileSdk 35
+
defaultConfig {
applicationId = "androidx.camera.integration.camera2.pipe"
}
diff --git a/camera/integration-tests/coretestapp/build.gradle b/camera/integration-tests/coretestapp/build.gradle
index 9b27d74..4b9931e 100644
--- a/camera/integration-tests/coretestapp/build.gradle
+++ b/camera/integration-tests/coretestapp/build.gradle
@@ -21,6 +21,8 @@
}
android {
+ compileSdk 35
+
defaultConfig {
applicationId = "androidx.camera.integration.core"
diff --git a/camera/integration-tests/diagnosetestapp/build.gradle b/camera/integration-tests/diagnosetestapp/build.gradle
index 70a9f1d..2db2390 100644
--- a/camera/integration-tests/diagnosetestapp/build.gradle
+++ b/camera/integration-tests/diagnosetestapp/build.gradle
@@ -21,6 +21,8 @@
}
android {
+ compileSdk 35
+
defaultConfig {
applicationId = "androidx.camera.integration.diagnose"
}
diff --git a/camera/integration-tests/extensionstestapp/build.gradle b/camera/integration-tests/extensionstestapp/build.gradle
index c2c09a4..03e2990 100644
--- a/camera/integration-tests/extensionstestapp/build.gradle
+++ b/camera/integration-tests/extensionstestapp/build.gradle
@@ -31,6 +31,8 @@
}
android {
+ compileSdk 35
+
defaultConfig {
applicationId = "androidx.camera.integration.extensions"
}
diff --git a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/PermissionUtil.kt b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/PermissionUtil.kt
index 1ed34c4..0b1bd0b 100644
--- a/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/PermissionUtil.kt
+++ b/camera/integration-tests/extensionstestapp/src/main/java/androidx/camera/integration/extensions/utils/PermissionUtil.kt
@@ -88,7 +88,10 @@
return arrayOfNulls(0)
}
- if (info.requestedPermissions == null || info.requestedPermissions.isEmpty()) {
+ if (
+ info.requestedPermissions == null ||
+ (info.requestedPermissions as Array<out Any>).isEmpty()
+ ) {
return arrayOfNulls(0)
}
@@ -99,7 +102,8 @@
// READ_EXTERNAL_STORAGE will also be included if we specify WRITE_EXTERNAL_STORAGE
// requirement in AndroidManifest.xml. Therefore, also need to skip the permission check
// of READ_EXTERNAL_STORAGE.
- for (permission in info.requestedPermissions) {
+ val requestedPermissions = info.requestedPermissions as Array<out Any>
+ for (permission in requestedPermissions) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
(Manifest.permission.WRITE_EXTERNAL_STORAGE == permission ||
@@ -108,7 +112,7 @@
continue
}
- requiredPermissions.add(permission)
+ requiredPermissions.add(permission.toString())
}
val permissions = requiredPermissions.toTypedArray<String?>()
diff --git a/camera/integration-tests/testingtestapp/build.gradle.kts b/camera/integration-tests/testingtestapp/build.gradle.kts
index e2c430a..3fbaddf 100644
--- a/camera/integration-tests/testingtestapp/build.gradle.kts
+++ b/camera/integration-tests/testingtestapp/build.gradle.kts
@@ -24,6 +24,8 @@
}
android {
+ compileSdk = 35
+
namespace = "androidx.camera.integration.testingtestapp"
defaultConfig {
diff --git a/camera/integration-tests/testingtestapp/src/main/java/androidx/camera/integration/testingtestapp/ui/theme/Theme.kt b/camera/integration-tests/testingtestapp/src/main/java/androidx/camera/integration/testingtestapp/ui/theme/Theme.kt
index 933cedc..44a8f40 100644
--- a/camera/integration-tests/testingtestapp/src/main/java/androidx/camera/integration/testingtestapp/ui/theme/Theme.kt
+++ b/camera/integration-tests/testingtestapp/src/main/java/androidx/camera/integration/testingtestapp/ui/theme/Theme.kt
@@ -50,6 +50,7 @@
*/
)
+@Suppress("DEPRECATION") // setStatusBarColor
@Composable
fun MultimoduleTemplateTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
diff --git a/camera/integration-tests/timingtestapp/build.gradle b/camera/integration-tests/timingtestapp/build.gradle
index 43c8180..896b44b 100644
--- a/camera/integration-tests/timingtestapp/build.gradle
+++ b/camera/integration-tests/timingtestapp/build.gradle
@@ -21,6 +21,8 @@
}
android {
+ compileSdk 35
+
defaultConfig {
applicationId = "androidx.camera.integration.antelope"
versionCode 35
diff --git a/camera/integration-tests/timingtestapp/src/main/java/androidx/camera/integration/antelope/TestUtils.kt b/camera/integration-tests/timingtestapp/src/main/java/androidx/camera/integration/antelope/TestUtils.kt
index 0f01e34..af82a97 100644
--- a/camera/integration-tests/timingtestapp/src/main/java/androidx/camera/integration/antelope/TestUtils.kt
+++ b/camera/integration-tests/timingtestapp/src/main/java/androidx/camera/integration/antelope/TestUtils.kt
@@ -776,7 +776,7 @@
/** Return the version name of the Activity */
@Suppress("DEPRECATION")
-fun getVersionName(activity: MainActivity): String {
+fun getVersionName(activity: MainActivity): String? {
val packageInfo = activity.packageManager.getPackageInfo(activity.packageName, 0)
return packageInfo.versionName
}
diff --git a/camera/viewfinder/viewfinder-core/samples/build.gradle b/camera/viewfinder/viewfinder-core/samples/build.gradle
index 0baad03..4f7cd42 100644
--- a/camera/viewfinder/viewfinder-core/samples/build.gradle
+++ b/camera/viewfinder/viewfinder-core/samples/build.gradle
@@ -38,6 +38,7 @@
}
android {
+ compileSdk 35
namespace = "androidx.camera.viewfinder.core.samples"
}