From 685845ec8e79148aff7fe64a94ef39744d7ef11a Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 26 Aug 2026 16:08:19 -0300 Subject: [PATCH] Add paused info bar. --- .../signal/camera/hud/StandardCameraHud.kt | 83 ++++++++++++++++--- .../java/org/signal/camera/test/TestTags.kt | 1 + .../camera/hud/StandardCameraHudTest.kt | 43 +++++++++- .../screens/capture/CameraXFragment.kt | 3 +- .../src/main/res/values/strings.xml | 2 + 5 files changed, 118 insertions(+), 14 deletions(-) diff --git a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt index 95424c780c..a274638a6f 100644 --- a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt +++ b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt @@ -18,6 +18,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -50,6 +51,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned @@ -87,6 +89,9 @@ private val ZOOM_BAR_BOTTOM_MARGIN = 16.dp /** How far the zoom bar sits in from the start edge on anything larger than a phone. */ private val ZOOM_BAR_SIDE_MARGIN = 16.dp +/** How long the time and the paused label take to trade the recording red between them. */ +private const val PAUSED_TRANSITION_MS = 200 + data class StringResources( @param:StringRes val photoCaptureFailed: Int = 0, @param:StringRes val photoProcessingFailed: Int = 0, @@ -94,7 +99,8 @@ data class StringResources( @param:StringRes val flashOff: Int = 0, @param:StringRes val flashOn: Int = 0, @param:StringRes val flashAuto: Int = 0, - @param:StringRes val send: Int = 0 + @param:StringRes val send: Int = 0, + @param:StringRes val recordingPaused: Int = 0 ) /** @@ -309,6 +315,8 @@ private fun BoxScope.StandardCameraHudContent( if (state.isRecording) { RecordingDurationDisplay( durationMillis = state.recordingDuration, + isPaused = state.isRecordingPaused, + pausedLabel = if (stringResources.recordingPaused != 0) stringResource(stringResources.recordingPaused) else null, modifier = Modifier .align(Alignment.TopCenter) .padding(top = 16.dp) @@ -716,27 +724,66 @@ private fun FlashAndCameraTogglePill( } } +/** + * The elapsed time, and below it while paused, the caller's label for a paused recording. + * + * @param pausedLabel What to call a paused recording, or null to show the time alone. + */ @Composable private fun RecordingDurationDisplay( durationMillis: Long, + isPaused: Boolean, + pausedLabel: String?, modifier: Modifier = Modifier ) { val seconds = (durationMillis / 1000) % 60 val minutes = (durationMillis / 1000) / 60 val timeText = String.format(Locale.US, "%02d:%02d", minutes, seconds) - Box( + val pausedProgress by animateFloatAsState( + targetValue = if (isPaused) 1f else 0f, + animationSpec = tween(PAUSED_TRANSITION_MS), + label = "RecordingPausedProgress" + ) + + val recordingRed = colorResource(R.color.CameraHud_control_red_background) + val pausedGray = colorResource(R.color.CameraHud_control_background) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier - .background(colorResource(R.color.CameraHud_control_red_background), shape = CircleShape) - .padding(horizontal = 16.dp, vertical = 4.dp) - .testTag(TestTags.CAMERA_HUD_RECORDING_DURATION) ) { - Text( - text = timeText, - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.Medium - ) + Box( + modifier = Modifier + .background(lerp(recordingRed, pausedGray, pausedProgress), shape = CircleShape) + .padding(horizontal = 16.dp, vertical = 4.dp) + .testTag(TestTags.CAMERA_HUD_RECORDING_DURATION) + ) { + Text( + text = timeText, + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Medium + ) + } + + if (pausedLabel != null && pausedProgress > 0f) { + Box( + modifier = Modifier + .padding(top = 8.dp) + .graphicsLayer { alpha = pausedProgress } + .background(recordingRed, shape = CircleShape) + .padding(horizontal = 16.dp, vertical = 4.dp) + .testTag(TestTags.CAMERA_HUD_RECORDING_PAUSED) + ) { + Text( + text = pausedLabel, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } } } @@ -903,6 +950,20 @@ private fun StandardCameraHudLockedRecordingPreview() { } } +/** Both pills, which the HUD previews cannot show without a caller to supply the label. */ +@Preview(name = "Recording duration", showBackground = true, backgroundColor = 0xFF444444) +@Composable +private fun RecordingDurationDisplayPreview() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + RecordingDurationDisplay(durationMillis = 12_000L, isPaused = false, pausedLabel = "Paused") + RecordingDurationDisplay(durationMillis = 12_000L, isPaused = true, pausedLabel = "Paused") + } +} + @Preview(name = "Video mode", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 640) @Composable private fun StandardCameraHudVideoModePreview() { diff --git a/feature/camera/src/main/java/org/signal/camera/test/TestTags.kt b/feature/camera/src/main/java/org/signal/camera/test/TestTags.kt index a7f05328da..72313562fc 100644 --- a/feature/camera/src/main/java/org/signal/camera/test/TestTags.kt +++ b/feature/camera/src/main/java/org/signal/camera/test/TestTags.kt @@ -21,6 +21,7 @@ object TestTags { const val CAMERA_HUD_SWITCH_BUTTON = "camera_hud_switch_button" const val CAMERA_HUD_CAPTURE_BUTTON = "camera_hud_capture_button" const val CAMERA_HUD_RECORDING_DURATION = "camera_hud_recording_duration" + const val CAMERA_HUD_RECORDING_PAUSED = "camera_hud_recording_paused" const val CAMERA_HUD_ZOOM_BAR = "camera_hud_zoom_bar" // What the gallery's corner holds, one at a time diff --git a/feature/camera/src/test/java/org/signal/camera/hud/StandardCameraHudTest.kt b/feature/camera/src/test/java/org/signal/camera/hud/StandardCameraHudTest.kt index b737c32e56..6d27cc96fa 100644 --- a/feature/camera/src/test/java/org/signal/camera/hud/StandardCameraHudTest.kt +++ b/feature/camera/src/test/java/org/signal/camera/hud/StandardCameraHudTest.kt @@ -27,6 +27,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import org.signal.camera.CameraScreenState import org.signal.camera.test.TestTags @@ -49,6 +50,9 @@ class StandardCameraHudTest { private var longPressTimeoutMillis = 0L + private val pausedLabel: String + get() = RuntimeEnvironment.getApplication().getString(PAUSED_LABEL_RES) + //region What holds the corner beside the capture button @Test @@ -266,6 +270,32 @@ class StandardCameraHudTest { composeTestRule.onNodeWithText("01:05").assertIsDisplayed() } + @Test + fun `Given a recording that is running, when displayed, then nothing says it is paused`() { + setContent(state = lockedRecording(), stringResources = pausedStringResources()) + + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_PAUSED).assertDoesNotExist() + } + + @Test + fun `Given a paused recording, when displayed, then the caller's word for it is shown below the time`() { + setContent(state = pausedRecording().copy(recordingDuration = 12_000L), stringResources = pausedStringResources()) + + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_DURATION).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_PAUSED).assertIsDisplayed() + composeTestRule.onNodeWithText(pausedLabel).assertIsDisplayed() + composeTestRule.onNodeWithText("00:12").assertIsDisplayed() + } + + /** A caller that leaves the label out gets the time alone rather than an empty pill under it. */ + @Test + fun `Given a paused recording and a caller with no word for it, when displayed, then only the time is shown`() { + setContent(state = pausedRecording()) + + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_DURATION).assertIsDisplayed() + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_PAUSED).assertDoesNotExist() + } + //endregion //region The same controls on a window too large for the bottom bar @@ -345,10 +375,15 @@ class StandardCameraHudTest { zoomRange = ZOOM_RANGE ) + private fun pausedRecording() = lockedRecording().copy(isRecordingPaused = true) + + private fun pausedStringResources() = StringResources(recordingPaused = PAUSED_LABEL_RES) + private fun setContent( state: CameraScreenState = CameraScreenState(zoomRange = ZOOM_RANGE), captureButtonMode: CaptureButtonMode = CaptureButtonMode.PHOTO, - maxRecordingDurationMs: Long = 0L + maxRecordingDurationMs: Long = 0L, + stringResources: StringResources = StringResources() ) { composeTestRule.setContent { longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis @@ -358,7 +393,8 @@ class StandardCameraHudTest { state = state, emitter = { events += it }, captureButtonMode = captureButtonMode, - maxRecordingDurationMs = maxRecordingDurationMs + maxRecordingDurationMs = maxRecordingDurationMs, + stringResources = stringResources ) } } @@ -379,5 +415,8 @@ class StandardCameraHudTest { private val ZOOM_RANGE = 0.5f..10f private const val MAX_DURATION = 30_000L + + /** The camera module carries no strings of its own, so a framework one stands in for the caller's label. */ + private val PAUSED_LABEL_RES = android.R.string.ok } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXFragment.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXFragment.kt index 0a8583f79b..1a545be6c5 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXFragment.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXFragment.kt @@ -631,7 +631,8 @@ fun CameraXScreen( }, stringResources = StringResources( photoCaptureFailed = R.string.CameraXFragment_photo_capture_failed, - photoProcessingFailed = R.string.CameraXFragment_photo_processing_failed + photoProcessingFailed = R.string.CameraXFragment_photo_processing_failed, + recordingPaused = R.string.CameraXFragment_paused ) ) } diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index ebea0e88f7..13afd592f2 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -183,6 +183,8 @@ Flash auto Send + + Paused Signal needs microphone permissions to record videos, but they have been denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\" and \"Camera\".