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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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())
}
}
}
}
}
Expand Down Expand Up @@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +59 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The test videoScreenTorchFrame_sizeFillsMaxSize does not perform any asynchronous operations or coroutine-based assertions. Using runTest here is unnecessary and can be simplified to a standard test function.

    @Test\n    fun videoScreenTorchFrame_sizeFillsMaxSize() {\n        composeTestRule.awaitIdle()\n        val rootBounds = composeTestRule.onRoot().getBoundsInRoot()\n        composeTestRule.onNode(hasTestTag(VIDEO_SCREEN_TORCH_OVERLAY))\n            .assertWidthIsAtLeast(rootBounds.width)\n        composeTestRule.onNode(hasTestTag(VIDEO_SCREEN_TORCH_OVERLAY))\n            .assertHeightIsAtLeast(rootBounds.height)\n    }


@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())
}
Comment on lines +69 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The test videoScreenTorchFrame_borderIsWhiteAndCenterIsNotWhite does not use any coroutines or suspend functions. You can remove runTest and define it as a standard test function.

    @Test\n    fun videoScreenTorchFrame_borderIsWhiteAndCenterIsNotWhite() {\n        composeTestRule.awaitIdle()\n        val frameImage =\n            composeTestRule.onNode(hasTestTag(VIDEO_SCREEN_TORCH_OVERLAY)).captureToImage()\n\n        val borderPixel = IntArray(1)\n        frameImage.readPixels(\n            borderPixel,\n            frameImage.width / 2,\n            1,\n            1,\n            1\n        )\n        assertThat(borderPixel[0]).isEqualTo(Color.White.toArgb())\n\n        val centerPixel = IntArray(1)\n        frameImage.readPixels(\n            centerPixel,\n            frameImage.width / 2,\n            frameImage.height / 2,\n            1,\n            1\n        )\n        assertThat(centerPixel[0]).isNotEqualTo(Color.White.toArgb())\n    }

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
)
}
}
Comment on lines +142 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of manually drawing four separate rectangles using a custom Canvas to create a border, you can use a standard Box with Modifier.border. This is more idiomatic in Jetpack Compose, simplifies the code, and avoids manual coordinate calculations.\n\nNote that Modifier.border draws the border inside the boundaries of the composable, and keeps the center transparent, which matches your requirements perfectly. Make sure to import androidx.compose.foundation.border.

@Composable\nfun VideoScreenTorchFrame(modifier: Modifier = Modifier) {\n    Box(\n        modifier = modifier\n            .testTag(VIDEO_SCREEN_TORCH_OVERLAY)\n            .fillMaxSize()\n            .border(width = VIDEO_SCREEN_TORCH_BORDER_WIDTH, color = Color.White)\n    )\n}


@Composable
private fun BrightnessMaximization(onInitialBrightnessCalculated: (Float) -> Unit) {
// This Composable is attached to Activity in current code, so will have Activity context.
Expand All @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository style guide (section 10, Test Tags and Semantics), the string value of a test tag must be a lower_snake_case string following the schema element_purpose_value. Please update the string value of VIDEO_SCREEN_TORCH_OVERLAY to follow this convention (e.g., "overlay_video_screen_torch").

Suggested change
const val VIDEO_SCREEN_TORCH_OVERLAY = "VideoScreenTorchOverlay"
const val VIDEO_SCREEN_TORCH_OVERLAY = "overlay_video_screen_torch"
References
  1. Test Tag Naming Convention: When creating a new test tag, the constant name must be in UPPER_SNAKE_CASE, and the string value must be a lower_snake_case string following a specific schema: element_purpose_value. (link)

const val AUDIO_INPUT_TOGGLE = "AudioInputToggle"
const val FOCUS_METERING_INDICATOR_TAG = "FocusMeteringIndicatorTag"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading