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\".