Repository navigation
Add front camera screen torch for video capture #641
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
|
|
||
| @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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test @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 |
|---|---|---|
|
|
@@ -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) | ||
| ) | ||
| } | ||
| } | ||
|
Comment on lines
+142
to
+171
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of manually drawing four separate rectangles using a custom @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. | ||
|
|
@@ -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) | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to the repository style guide (section 10, Test Tags and Semantics), the string value of a test tag must be a
Suggested change
References
|
||||||
| const val AUDIO_INPUT_TOGGLE = "AudioInputToggle" | ||||||
| const val FOCUS_METERING_INDICATOR_TAG = "FocusMeteringIndicatorTag" | ||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test
videoScreenTorchFrame_sizeFillsMaxSizedoes not perform any asynchronous operations or coroutine-based assertions. UsingrunTesthere is unnecessary and can be simplified to a standard test function.