From 7d624736f9ec9fe30ad38f41e0169a2bf6789324 Mon Sep 17 00:00:00 2001 From: SoufianeDev Date: Thu, 5 Nov 2026 02:03:50 +0100 Subject: [PATCH] Add front camera video screen torch Illuminate the subject during front camera video capture by maximizing the screen brightness and drawing a white frame around the viewfinder. Auto is treated like On, matching the existing front photo screen flash behavior. The brightness is restored once both the photo screen flash and the video screen torch are inactive, including when a photo is taken while recording. Tests cover the derived UI state and the torch frame rendering. --- README.md | 14 +-- .../feature/preview/PreviewScreen.kt | 43 ++++++--- .../capture/VideoScreenTorchFrameTest.kt | 95 +++++++++++++++++++ .../capture/ScreenFlashComponents.kt | 76 +++++++++++++-- .../ui/components/capture/TestTags.kt | 1 + .../capture/compound/CaptureUiState.kt | 5 + .../capture/compound/CaptureUiStateAdapter.kt | 12 +++ .../capture/CaptureUiStateAdapterTest.kt | 87 +++++++++++++++++ 8 files changed, 308 insertions(+), 25 deletions(-) create mode 100644 ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/VideoScreenTorchFrameTest.kt diff --git a/README.md b/README.md index b13307a74a..0e7622e851 100644 --- a/README.md +++ b/README.md @@ -440,23 +440,25 @@ operation, offering specialized functionalities and enhanced capture modes. During image capture with a front-facing camera, this feature illuminates the subject by displaying a solid, bright overlay on the device's screen and maximizing screen brightness. +During video capture with a front-facing camera, the screen acts as a continuous torch: the screen +brightness is maximized and a white frame is drawn around the viewfinder, so the subject stays lit +for the whole recording while the preview and controls remain usable. + #### How to Enable / Use 1. Open the camera app. 2. Ensure a front-facing camera is selected by tapping on the “flip camera” button. 3. Tap the flash icon to toggle between scene illumination mode. Screen flash will be selected if the “On” or “Auto” flash mode is selected. -4. Take a photo. +4. Take a photo or start a video recording. See the “Scene Illumination / Flash” feature for other methods of enabling screen flash. #### Constraints / Limitations -* Screen flash will only be used for front-facing cameras. -* Screen flash is currently limited to image capture only. There will be no additional illumination - for video capture. -* In "Auto" mode, screen flash behaves equivalently to the "On" mode, regardless of ambient scene - lighting. +* Screen flash and the video screen torch will only be used for front-facing cameras. +* In "Auto" mode, screen flash and the video screen torch behave equivalently to the "On" mode, + regardless of ambient scene lighting. --- diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index aacff43a8a..5224402b91 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -27,7 +27,9 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.material3.BottomSheetScaffoldState @@ -91,6 +93,7 @@ import com.google.jetpackcamera.ui.components.capture.StabilizationIcon import com.google.jetpackcamera.ui.components.capture.TestableSnackbar import com.google.jetpackcamera.ui.components.capture.VIDEO_QUALITY_TAG import com.google.jetpackcamera.ui.components.capture.VideoQualityIcon +import com.google.jetpackcamera.ui.components.capture.VideoScreenTorchFrame import com.google.jetpackcamera.ui.components.capture.ZoomBar import com.google.jetpackcamera.ui.components.capture.ZoomStateManager import com.google.jetpackcamera.ui.components.capture.debouncedOrientationFlow @@ -452,10 +455,16 @@ internal fun ContentScreen( currentCaptureUiStateProvider().focusMeteringUiState } } + val videoScreenTorchState = remember { + derivedStateOf { + currentCaptureUiStateProvider().isVideoScreenTorchActive + } + } val viewfinderLambda = remember( isErrorDialogShowing, previewDisplayState, focusMeteringState, + videoScreenTorchState, onFlipCamera, onTapToFocusLambda, onScaleZoomLambda, @@ -464,16 +473,21 @@ internal fun ContentScreen( ) { @Composable { modifier: Modifier -> if (!isErrorDialogShowing) { - PreviewDisplay( - modifier = modifier, - previewDisplayUiState = previewDisplayState.value, - onFlipCamera = onFlipCamera, - onTapToFocus = onTapToFocusLambda, - onScaleZoom = { zoomRatio -> onScaleZoomLambda(zoomRatio) }, - surfaceRequest = surfaceRequest, - onRequestWindowColorMode = onRequestWindowColorMode, - focusMeteringUiState = focusMeteringState.value - ) + Box(modifier) { + PreviewDisplay( + modifier = Modifier.fillMaxSize(), + previewDisplayUiState = previewDisplayState.value, + onFlipCamera = onFlipCamera, + onTapToFocus = onTapToFocusLambda, + onScaleZoom = { zoomRatio -> onScaleZoomLambda(zoomRatio) }, + surfaceRequest = surfaceRequest, + onRequestWindowColorMode = onRequestWindowColorMode, + focusMeteringUiState = focusMeteringState.value + ) + if (videoScreenTorchState.value) { + VideoScreenTorchFrame(Modifier.fillMaxSize()) + } + } } } } @@ -729,13 +743,18 @@ internal fun ContentScreen( currentCaptureUiStateProvider().screenFlashUiState } } - val screenFlashOverlayLambda = remember(screenFlashState, screenFlashController) { + val screenFlashOverlayLambda = remember( + screenFlashState, + videoScreenTorchState, + screenFlashController + ) { @Composable { modifier: Modifier -> ScreenFlashScreen( screenFlashUiState = screenFlashState.value, onInitialBrightnessCalculated = { screenFlashController?.setClearUiScreenBrightness(it) - } + }, + isVideoScreenTorchEnabled = videoScreenTorchState.value ) } } diff --git a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/VideoScreenTorchFrameTest.kt b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/VideoScreenTorchFrameTest.kt new file mode 100644 index 0000000000..3cdefd1d71 --- /dev/null +++ b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/VideoScreenTorchFrameTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2026 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 com.google.jetpackcamera.ui.components.capture + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertWidthIsAtLeast +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class VideoScreenTorchFrameTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setUp() { + composeTestRule.setContent { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + VideoScreenTorchFrame(Modifier.fillMaxSize()) + } + } + } + + @Test + fun videoScreenTorchFrame_sizeFillsMaxSize() = runTest { + composeTestRule.awaitIdle() + val rootBounds = composeTestRule.onRoot().getBoundsInRoot() + composeTestRule.onNode(hasTestTag(VIDEO_SCREEN_TORCH_OVERLAY)) + .assertWidthIsAtLeast(rootBounds.width) + composeTestRule.onNode(hasTestTag(VIDEO_SCREEN_TORCH_OVERLAY)) + .assertHeightIsAtLeast(rootBounds.height) + } + + @Test + fun videoScreenTorchFrame_borderIsWhiteAndCenterIsNotWhite() = runTest { + composeTestRule.awaitIdle() + val frameImage = + composeTestRule.onNode(hasTestTag(VIDEO_SCREEN_TORCH_OVERLAY)).captureToImage() + + val borderPixel = IntArray(1) + frameImage.readPixels( + borderPixel, + frameImage.width / 2, + 1, + 1, + 1 + ) + assertThat(borderPixel[0]).isEqualTo(Color.White.toArgb()) + + val centerPixel = IntArray(1) + frameImage.readPixels( + centerPixel, + frameImage.width / 2, + frameImage.height / 2, + 1, + 1 + ) + assertThat(centerPixel[0]).isNotEqualTo(Color.White.toArgb()) + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt index b81a3359ee..5e9216b057 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt @@ -22,40 +22,55 @@ import android.view.WindowManager import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp import com.google.jetpackcamera.ui.uistate.capture.ScreenFlashUiState import kotlinx.coroutines.yield private const val TAG = "ScreenFlashComponents" /** - * A composable that manages the screen flash effect during image capture. + * Width of the white frame drawn around the viewfinder while the video screen torch is active. + * The frame provides most of the illumination while leaving the preview and controls usable. + */ +private val VIDEO_SCREEN_TORCH_BORDER_WIDTH = 56.dp + +/** + * A composable that manages the screen illumination effects used for capture. * - * This component displays a full-screen white overlay to simulate a flash and maximizes - * the device's screen brightness when enabled. After the flash, it restores the screen - * to its original brightness. + * This component displays a full-screen white overlay to simulate a photo flash and maximizes the + * device's screen brightness while either the photo screen flash or the video screen torch is + * active. After illumination ends, it restores the screen to its original brightness. * * @param screenFlashUiState The [ScreenFlashUiState] that controls the screen flash's state. * @param onInitialBrightnessCalculated Callback to provide the initial screen brightness before maximization. + * @param isVideoScreenTorchEnabled Whether the screen should act as a continuous torch while + * recording video with the front camera. When enabled, the screen brightness is maximized until + * both the photo screen flash and the video screen torch are inactive. */ @Composable fun ScreenFlashScreen( screenFlashUiState: ScreenFlashUiState, - onInitialBrightnessCalculated: (Float) -> Unit + onInitialBrightnessCalculated: (Float) -> Unit, + isVideoScreenTorchEnabled: Boolean = false ) { ScreenFlashOverlay(screenFlashUiState) - if (screenFlashUiState.enabled) { + if (screenFlashUiState.enabled || isVideoScreenTorchEnabled) { BrightnessMaximization(onInitialBrightnessCalculated = onInitialBrightnessCalculated) } else { screenFlashUiState.screenBrightnessToRestore?.let { @@ -114,6 +129,47 @@ private fun ScreenFlashOverlay( ) } +/** + * A composable that draws a white frame to use the screen as a torch while recording video. + * + * The frame maximizes the light emitted towards the subject while leaving the center of the screen, + * where the preview is, untouched. It is drawn inside the viewfinder slot so that the controls stay + * visible and usable, and it does not consume pointer input. + * + * @param modifier The modifier for this composable. + */ +@Composable +fun VideoScreenTorchFrame(modifier: Modifier = Modifier) { + Canvas( + modifier = modifier + .testTag(VIDEO_SCREEN_TORCH_OVERLAY) + .fillMaxSize() + ) { + val borderWidth = VIDEO_SCREEN_TORCH_BORDER_WIDTH.toPx() + .coerceAtMost(size.minDimension / 2f) + // Draw four bars instead of a stroked rectangle so that the frame is fully opaque and the + // center of the screen stays transparent. + drawRect( + color = Color.White, + size = Size(width = size.width, height = borderWidth) + ) + drawRect( + color = Color.White, + topLeft = Offset(x = 0f, y = size.height - borderWidth), + size = Size(width = size.width, height = borderWidth) + ) + drawRect( + color = Color.White, + size = Size(width = borderWidth, height = size.height) + ) + drawRect( + color = Color.White, + topLeft = Offset(x = size.width - borderWidth, y = 0f), + size = Size(width = borderWidth, height = size.height) + ) + } +} + @Composable private fun BrightnessMaximization(onInitialBrightnessCalculated: (Float) -> Unit) { // This Composable is attached to Activity in current code, so will have Activity context. @@ -130,8 +186,14 @@ private fun BrightnessMaximization(onInitialBrightnessCalculated: (Float) -> Uni onInitialBrightnessCalculated(initialScreenBrightness) } - LaunchedEffect(Unit) { + DisposableEffect(activity) { setBrightness(activity, WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL) + onDispose { + // Restore the original brightness when illumination ends. This also covers the case + // where a photo screen flash clears the shared UI state while the video screen torch + // is still active, which would otherwise leave the screen at maximum brightness. + setBrightness(activity, initialScreenBrightness) + } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt index 73fe60d5c5..b6c8e4e595 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt @@ -35,6 +35,7 @@ const val IMAGE_WELL_TAG = "ImageWellTag" const val PREVIEW_DISPLAY = "PreviewDisplay" const val SCREEN_FLASH_OVERLAY = "ScreenFlashOverlay" +const val VIDEO_SCREEN_TORCH_OVERLAY = "VideoScreenTorchOverlay" const val AUDIO_INPUT_TOGGLE = "AudioInputToggle" const val FOCUS_METERING_INDICATOR_TAG = "FocusMeteringIndicatorTag" diff --git a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/compound/CaptureUiState.kt b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/compound/CaptureUiState.kt index 15edea0dad..a43b6eeab0 100644 --- a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/compound/CaptureUiState.kt +++ b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/compound/CaptureUiState.kt @@ -73,6 +73,10 @@ sealed interface CaptureUiState { * @property zoomControlUiState The UI state for the zoom control buttons. * @property hdrUiState The UI state for the HDR setting. * @property focusMeteringUiState The UI state for focus and metering. + * @property isVideoScreenTorchActive Whether the screen should be used as a torch while + * recording video with the front-facing camera. When `true`, the screen is kept at maximum + * brightness and a white frame is drawn so that the subject is illuminated without hiding the + * preview or controls. * @property cameraErrorUiState The UI state for camera errors. */ data class Ready( @@ -101,6 +105,7 @@ sealed interface CaptureUiState { val hdrUiState: HdrUiState = HdrUiState.Unavailable, val focusMeteringUiState: FocusMeteringUiState = FocusMeteringUiState.Unspecified, val screenFlashUiState: ScreenFlashUiState = ScreenFlashUiState(), + val isVideoScreenTorchActive: Boolean = false, val cameraErrorUiState: CameraErrorUiState = CameraErrorUiState.Hidden ) : CaptureUiState diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt index 2fc16c4c62..fa11ae9f97 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/compound/CaptureUiStateAdapter.kt @@ -20,6 +20,8 @@ import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.CaptureSubModeId import com.google.jetpackcamera.model.ExternalCaptureMode +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.VideoQuality import com.google.jetpackcamera.settings.model.CameraAppSettings import com.google.jetpackcamera.settings.model.CameraFeaturePolicy @@ -155,6 +157,15 @@ fun captureUiState( systemConstraints = systemConstraints, optionVisibility = effectiveFeaturePolicy?.flashMode?.visibility ) + // Most front-facing cameras have no flash unit, so the screen itself illuminates the + // subject during video capture. Auto is treated like On, matching front photo screen flash. + val isVideoScreenTorchActive = + roundedVideoRecordingState !is VideoRecordingState.Inactive && + cameraAppSettings.cameraLensFacing == LensFacing.FRONT && + when (cameraAppSettings.flashMode) { + FlashMode.ON, FlashMode.AUTO -> true + else -> false + } focusMeteringUiState = focusMeteringUiState?.updateFrom( cameraState = roundedCameraState ) ?: FocusMeteringUiState.from(roundedCameraState) @@ -230,6 +241,7 @@ fun captureUiState( roundedVideoRecordingState ), screenFlashUiState = ScreenFlashUiState.from(trackedUiState), + isVideoScreenTorchActive = isVideoScreenTorchActive, cameraErrorUiState = CameraErrorUiState.from(roundedCameraState) ) } diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt index 820676f30d..ff7f0020e1 100644 --- a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/CaptureUiStateAdapterTest.kt @@ -432,6 +432,83 @@ internal class CaptureUiStateAdapterTest { assertThat(videoOnlyState.videoQuality).isEqualTo(VideoQuality.FHD) } + @Test + fun captureUiState_frontVideoRecordingWithFlashOn_enablesVideoScreenTorch() = runTest { + assertVideoScreenTorchActive( + lensFacing = LensFacing.FRONT, + flashMode = FlashMode.ON, + videoRecordingState = ACTIVE_RECORDING_STATE, + expected = true + ) + } + + @Test + fun captureUiState_frontVideoRecordingWithFlashAuto_enablesVideoScreenTorch() = runTest { + assertVideoScreenTorchActive( + lensFacing = LensFacing.FRONT, + flashMode = FlashMode.AUTO, + videoRecordingState = ACTIVE_RECORDING_STATE, + expected = true + ) + } + + @Test + fun captureUiState_frontVideoRecordingWithFlashOff_disablesVideoScreenTorch() = runTest { + assertVideoScreenTorchActive( + lensFacing = LensFacing.FRONT, + flashMode = FlashMode.OFF, + videoRecordingState = ACTIVE_RECORDING_STATE, + expected = false + ) + } + + @Test + fun captureUiState_backVideoRecordingWithFlashOn_disablesVideoScreenTorch() = runTest { + assertVideoScreenTorchActive( + lensFacing = LensFacing.BACK, + flashMode = FlashMode.ON, + videoRecordingState = ACTIVE_RECORDING_STATE, + expected = false + ) + } + + @Test + fun captureUiState_frontCameraInactiveWithFlashOn_disablesVideoScreenTorch() = runTest { + assertVideoScreenTorchActive( + lensFacing = LensFacing.FRONT, + flashMode = FlashMode.ON, + videoRecordingState = VideoRecordingState.Inactive(), + expected = false + ) + } + + @Test + fun captureUiState_frontPausedRecordingWithFlashOn_enablesVideoScreenTorch() = runTest { + assertVideoScreenTorchActive( + lensFacing = LensFacing.FRONT, + flashMode = FlashMode.ON, + videoRecordingState = PAUSED_RECORDING_STATE, + expected = true + ) + } + + private suspend fun assertVideoScreenTorchActive( + lensFacing: LensFacing, + flashMode: FlashMode, + videoRecordingState: VideoRecordingState, + expected: Boolean + ) { + cameraSystem.setLensFacing(lensFacing) + cameraSystem.setFlashMode(flashMode) + cameraSystem.setCurrentCameraState( + CameraState(videoRecordingState = videoRecordingState) + ) + + val state = assertIsReady(createCaptureUiStateFlow().first()) + + assertThat(state.isVideoScreenTorchActive).isEqualTo(expected) + } + private fun assertIsReady(uiState: CaptureUiState): CaptureUiState.Ready = when (uiState) { is CaptureUiState.Ready -> uiState else -> throw AssertionError( @@ -442,5 +519,15 @@ internal class CaptureUiStateAdapterTest { private companion object { val TEST_SUB_MODE_ID = CaptureSubModeId("test_sub_mode") const val SUB_MODE_QUICK_SETTINGS_TITLE_RES_ID = 2001 + val ACTIVE_RECORDING_STATE = VideoRecordingState.Active.Recording( + maxDurationMillis = 0L, + audioStreamState = AudioStreamState.Disabled, + elapsedTimeNanos = 0L + ) + val PAUSED_RECORDING_STATE = VideoRecordingState.Active.Paused( + maxDurationMillis = 0L, + audioStreamState = AudioStreamState.Disabled, + elapsedTimeNanos = 0L + ) } }