diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt b/core/ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt
index 274e6fc826..97515640cb 100644
--- a/core/ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt
+++ b/core/ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt
@@ -13,12 +13,20 @@ import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
import org.signal.core.ui.R
/**
@@ -62,6 +70,43 @@ fun Modifier.clickableContainer(
}
)
+/**
+ * Fades this component's content out as it nears its end edge, so that whatever floats over that edge has nothing
+ * running underneath it.
+ *
+ * Content within [inset] of the edge is gone altogether, and [fadeWidth] before that is the ramp into it. The same ramp
+ * covers content leaving the viewport, since both are the one edge.
+ *
+ * The fade follows the layout direction, landing on the left in RTL.
+ *
+ * @param fadeWidth How far the ramp from solid to gone runs
+ * @param inset How far in from the end edge the ramp finishes, leaving everything past it fully faded
+ */
+fun Modifier.endFadingEdge(fadeWidth: Dp, inset: Dp = 0.dp): Modifier {
+ return this
+ .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
+ .drawWithContent {
+ drawContent()
+
+ if (fadeWidth <= 0.dp && inset <= 0.dp) {
+ return@drawWithContent
+ }
+
+ val insetPx = inset.toPx()
+
+ // A ramp with no width is no gradient at all, so it is given the thinnest one that still has two ends.
+ val fadePx = fadeWidth.toPx().coerceAtLeast(1f)
+
+ val brush = if (layoutDirection == LayoutDirection.Rtl) {
+ Brush.horizontalGradient(listOf(Color.Transparent, Color.Black), startX = insetPx, endX = insetPx + fadePx)
+ } else {
+ Brush.horizontalGradient(listOf(Color.Black, Color.Transparent), startX = size.width - insetPx - fadePx, endX = size.width - insetPx)
+ }
+
+ drawRect(brush = brush, blendMode = BlendMode.DstIn)
+ }
+}
+
fun Modifier.ensureWidthIsAtLeastHeight(): Modifier {
return this.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt
index d2cbe710db..44d84572a9 100644
--- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt
+++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt
@@ -76,9 +76,11 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Nighttime(icon(R.drawable.ic_nighttime_26)),
NumberPad(icon(R.drawable.ic_number_pad_conversation_filter_24)),
Open(icon(R.drawable.symbol_open_24)),
+ Pause(icon(R.drawable.symbol_pause_24)),
PersonCircle(icon(R.drawable.symbol_person_circle_24)),
Phone(icon(R.drawable.symbol_phone_24)),
Photo(icon(R.drawable.symbol_photo_24)),
+ Play(icon(R.drawable.symbol_play_24)),
Plus(icon(R.drawable.symbol_plus_24)),
QrCode(icon(R.drawable.symbol_qrcode_24)),
QualityHigh(icon(R.drawable.symbol_quality_high_24)),
diff --git a/core/ui/src/main/res/drawable/symbol_pause_24.xml b/core/ui/src/main/res/drawable/symbol_pause_24.xml
new file mode 100644
index 0000000000..ea29a4d464
--- /dev/null
+++ b/core/ui/src/main/res/drawable/symbol_pause_24.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/symbol_play_24.xml b/core/ui/src/main/res/drawable/symbol_play_24.xml
new file mode 100644
index 0000000000..89c9d1b1a3
--- /dev/null
+++ b/core/ui/src/main/res/drawable/symbol_play_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt
index 52cb593100..2d3dc7650b 100644
--- a/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt
+++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt
@@ -121,6 +121,7 @@ fun MainScreen(
cameraViewModel.startRecording(
context = context,
output = viewModel.createVideoOutput(context),
+ isRecordingLocked = event.isLocked,
onVideoCaptured = { result ->
viewModel.onEvent(MainScreenEvents.VideoSaved(result))
}
@@ -129,6 +130,12 @@ fun MainScreen(
is StandardCameraHudEvents.VideoCaptureStopped -> {
cameraViewModel.stopRecording()
}
+ is StandardCameraHudEvents.VideoCaptureLocked -> {
+ cameraViewModel.onEvent(CameraScreenEvents.LockRecording)
+ }
+ is StandardCameraHudEvents.RecordingPauseToggled -> {
+ cameraViewModel.onEvent(CameraScreenEvents.ToggleRecordingPaused)
+ }
is StandardCameraHudEvents.GalleryClick -> {
backStack.add(Screen.Gallery)
}
@@ -144,6 +151,9 @@ fun MainScreen(
is StandardCameraHudEvents.SetZoomLevel -> {
cameraViewModel.onEvent(CameraScreenEvents.LinearZoom(event.zoomLevel))
}
+ is StandardCameraHudEvents.SetZoomRatio -> {
+ cameraViewModel.onEvent(CameraScreenEvents.SetZoomRatio(event.zoomRatio))
+ }
is StandardCameraHudEvents.CloseClick -> {
// Doesn't need to be handled
}
diff --git a/feature/camera/build.gradle.kts b/feature/camera/build.gradle.kts
index 8427af8cc2..5d1c21861b 100644
--- a/feature/camera/build.gradle.kts
+++ b/feature/camera/build.gradle.kts
@@ -62,6 +62,7 @@ dependencies {
testImplementation(testLibs.assertk)
testImplementation(testLibs.kotlinx.coroutines.test)
testImplementation(testLibs.robolectric.robolectric)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(testLibs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
diff --git a/feature/camera/src/main/java/org/signal/camera/CameraScreenEvents.kt b/feature/camera/src/main/java/org/signal/camera/CameraScreenEvents.kt
index 57dfa878cb..8a2d13e57b 100644
--- a/feature/camera/src/main/java/org/signal/camera/CameraScreenEvents.kt
+++ b/feature/camera/src/main/java/org/signal/camera/CameraScreenEvents.kt
@@ -34,9 +34,21 @@ sealed interface CameraScreenEvents {
/** Zoom that happens when you pinch your fingers. */
data class PinchZoom(val zoomFactor: Float) : CameraScreenEvents
+ /** Zoom straight to a ratio, as picking a level off the zoom bar does. */
+ data class SetZoomRatio(val zoomRatio: Float) : CameraScreenEvents
+
/** Zoom that happens when you move your finger up and down during recording. Positive values zoom in, negative values zoom out. */
data class LinearZoom(@param:FloatRange(from = -1.0, to = 1.0) val linearZoom: Float) : CameraScreenEvents
+ /** Leaves the running recording going without the capture button being held. */
+ data object LockRecording : CameraScreenEvents
+
+ /**
+ * Pauses the running recording, or resumes a paused one. One event rather than two because only the recorder knows
+ * which of the two applies, and a caller reading that off the screen may be a moment behind.
+ */
+ data object ToggleRecordingPaused : CameraScreenEvents
+
/** Switches between available cameras (i.e. front and rear cameras). */
data class SwitchCamera(val context: Context) : CameraScreenEvents
diff --git a/feature/camera/src/main/java/org/signal/camera/CameraScreenState.kt b/feature/camera/src/main/java/org/signal/camera/CameraScreenState.kt
index a6d3650f39..77fd283914 100644
--- a/feature/camera/src/main/java/org/signal/camera/CameraScreenState.kt
+++ b/feature/camera/src/main/java/org/signal/camera/CameraScreenState.kt
@@ -14,8 +14,14 @@ data class CameraScreenState(
val showFocusIndicator: Boolean = false,
val lensFacing: Int = CameraSelector.LENS_FACING_BACK,
val zoomRatio: Float = 1f,
+ /** What the bound lens can reach. A single point until the camera reports otherwise. */
+ val zoomRange: ClosedFloatingPointRange = 1f..1f,
val flashMode: FlashMode = FlashMode.Off,
val isRecording: Boolean = false,
+ /** Whether the running recording keeps running without the capture button being held. */
+ val isRecordingLocked: Boolean = false,
+ /** Whether the running recording is paused, as the recorder reports it rather than as it was requested. */
+ val isRecordingPaused: Boolean = false,
val recordingDuration: Long = 0L,
val showShutter: Boolean = false,
val showSelfieFlash: Boolean = false,
diff --git a/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt b/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt
index cd0b14c13e..15d15355b9 100644
--- a/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt
+++ b/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt
@@ -26,6 +26,7 @@ import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
import androidx.camera.core.UseCase
+import androidx.camera.core.ZoomState
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
@@ -37,12 +38,15 @@ import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
+import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.geometry.Offset
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.zxing.BinaryBitmap
@@ -54,17 +58,21 @@ import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.signal.core.util.throttleLatest
import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.concurrent.Executors
+import kotlin.math.exp
+import kotlin.math.ln
import kotlin.time.Duration.Companion.nanoseconds
import kotlin.time.Duration.Companion.seconds
@@ -85,6 +93,17 @@ class CameraScreenViewModel : ViewModel() {
/** Requested resolution for the QR analysis stream. */
private val QR_ANALYSIS_RESOLUTION = Size(1280, 720)
+
+ /** A single point, so a lens that has not reported its range yet reads as one that cannot zoom. */
+ private val DEFAULT_ZOOM_RANGE = 1f..1f
+
+ /** How long a zoom animation to a level picked off the zoom bar runs for. */
+ private const val ZOOM_ANIMATION_DURATION_MS = 250L
+
+ /** One frame at 60Hz. */
+ private const val ZOOM_ANIMATION_FRAME_MS = 16L
+
+ private val ZOOM_ANIMATION_EASING = FastOutSlowInEasing
}
private val _state: MutableState = mutableStateOf(CameraScreenState())
@@ -106,6 +125,25 @@ class CameraScreenViewModel : ViewModel() {
private var surfaceProvider: Preview.SurfaceProvider? = null
private var recordingStartZoomRatio: Float = 1f
+ /** The in-flight animation to a level picked off the zoom bar. Anything else that moves the zoom cancels it. */
+ private var zoomAnimation: Job? = null
+
+ /**
+ * A lock that arrived before the recorder reported the recording as started, applied by [startRecordingTimer] once it
+ * does. Without it the HUD would settle into its held state with no finger on the capture button, where nothing but
+ * the duration cap can stop the recording.
+ */
+ private var pendingRecordingLock: Boolean = false
+
+ /**
+ * Set when a lens is bound, so that the first zoom it reports is taken as the current ratio. A rebind comes up at the
+ * new lens's own zoom rather than carrying the previous one's over.
+ */
+ private var needsZoomResync: Boolean = false
+
+ /** Null for a recording that finalizes without being asked to stop, such as one that hits the recorder's own limits. */
+ private var recordingStopwatch: Stopwatch? = null
+
private val _qrCodeDetected = MutableSharedFlow(extraBufferCapacity = 1)
/**
@@ -116,6 +154,31 @@ class CameraScreenViewModel : ViewModel() {
.throttleLatest(2.seconds)
.onEach { Log.i(TAG, "Decoded a QR code. payloadLength: ${it.length}") }
+ /**
+ * Whether a recording has been started and not yet finished. Unlike [CameraScreenState.isRecording] this is true from
+ * the moment [startRecording] is called rather than from when the recorder reports itself as running, so it is what a
+ * caller has to check before starting another.
+ */
+ val hasActiveRecording: Boolean
+ get() = recording != null
+
+ /** Held so that binding a different lens can stop observing the one before it. */
+ private var observedZoomState: LiveData? = null
+
+ private val zoomRangeObserver = Observer { zoomState ->
+ val zoomRange = zoomState.minZoomRatio..zoomState.maxZoomRatio
+
+ if (needsZoomResync) {
+ needsZoomResync = false
+ Log.d(TAG, "Bound lens reaches $zoomRange at ${zoomState.zoomRatio}x")
+ recordingStartZoomRatio = zoomState.zoomRatio
+ _state.value = _state.value.copy(zoomRange = zoomRange, zoomRatio = zoomState.zoomRatio)
+ } else if (zoomRange != _state.value.zoomRange) {
+ Log.d(TAG, "Bound lens reaches $zoomRange")
+ _state.value = _state.value.copy(zoomRange = zoomRange)
+ }
+ }
+
private val qrCodeReader = QRCodeReader()
private val qrCodeHint = EnumMap(DecodeHintType::class.java).apply {
set(DecodeHintType.TRY_HARDER, true)
@@ -139,6 +202,15 @@ class CameraScreenViewModel : ViewModel() {
is CameraScreenEvents.LinearZoom -> {
handleSetLinearZoomEvent(currentState, event.linearZoom)
}
+ is CameraScreenEvents.SetZoomRatio -> {
+ handleSetZoomRatioEvent(currentState, event.zoomRatio)
+ }
+ is CameraScreenEvents.LockRecording -> {
+ handleLockRecordingEvent(currentState)
+ }
+ is CameraScreenEvents.ToggleRecordingPaused -> {
+ handleToggleRecordingPausedEvent(currentState)
+ }
is CameraScreenEvents.SwitchCamera -> {
handleSwitchCameraEvent(currentState)
}
@@ -163,6 +235,9 @@ class CameraScreenViewModel : ViewModel() {
is CameraScreenEvents.TapToFocus -> Log.d(TAG, "[Event] TapToFocus(view=${event.viewX},${event.viewY}, surface=${event.surfaceX},${event.surfaceY})")
is CameraScreenEvents.PinchZoom -> Log.d(TAG, "[Event] PinchZoom(factor=${event.zoomFactor})")
is CameraScreenEvents.LinearZoom -> Log.d(TAG, "[Event] LinearZoom(${event.linearZoom})")
+ is CameraScreenEvents.SetZoomRatio -> Log.d(TAG, "[Event] SetZoomRatio(${event.zoomRatio})")
+ is CameraScreenEvents.LockRecording -> Log.d(TAG, "[Event] LockRecording")
+ is CameraScreenEvents.ToggleRecordingPaused -> Log.d(TAG, "[Event] ToggleRecordingPaused")
is CameraScreenEvents.SwitchCamera -> Log.d(TAG, "[Event] SwitchCamera")
is CameraScreenEvents.SetFlashMode -> Log.d(TAG, "[Event] SetFlashMode(${event.flashMode})")
is CameraScreenEvents.NextFlashMode -> Log.d(TAG, "[Event] NextFlashMode")
@@ -265,12 +340,16 @@ class CameraScreenViewModel : ViewModel() {
/**
* Start video recording.
* If flash is enabled, turns on the torch for the duration of the recording.
+ *
+ * @param isRecordingLocked Whether the recording runs until [stopRecording] rather than for only as long as the
+ * caller's gesture. Cleared alongside [CameraScreenState.isRecording] so it cannot outlive the recording.
*/
@androidx.annotation.OptIn(markerClass = [androidx.camera.core.ExperimentalGetImage::class])
@SuppressLint("MissingPermission", "RestrictedApi", "NewApi")
fun startRecording(
context: Context,
output: VideoOutput,
+ isRecordingLocked: Boolean = false,
onVideoCaptured: (VideoCaptureResult) -> Unit
) {
val capture = videoCapture ?: rebindForVideoCapture() ?: return
@@ -312,16 +391,28 @@ class CameraScreenViewModel : ViewModel() {
when (recordEvent) {
is VideoRecordEvent.Start -> {
Log.d(TAG, "Video recording started")
- startRecordingTimer()
+ startRecordingTimer(isRecordingLocked)
vibrate(context)
}
+ is VideoRecordEvent.Pause -> {
+ Log.d(TAG, "Video recording paused")
+ _state.value = _state.value.copy(isRecordingPaused = true)
+ }
+ is VideoRecordEvent.Resume -> {
+ Log.d(TAG, "Video recording resumed")
+ _state.value = _state.value.copy(isRecordingPaused = false)
+ }
is VideoRecordEvent.Finalize -> {
+ val stopwatch = recordingStopwatch
+ recordingStopwatch = null
+ stopwatch?.split("finalize")
+
if (enableTorch) {
camera?.cameraControl?.enableTorch(false)
}
val result = if (!recordEvent.hasError()) {
- Log.d(TAG, "Video recording succeeded")
+ Log.d(TAG, "Video recording succeeded. bytes: ${recordEvent.recordingStats.numBytesRecorded}")
val durationMs = recordEvent.recordingStats.recordedDurationNanos.nanoseconds.inWholeMilliseconds
when (output) {
is VideoOutput.FileOutput -> {
@@ -343,6 +434,10 @@ class CameraScreenViewModel : ViewModel() {
// Call the callback
onVideoCaptured(result)
+
+ stopwatch?.split("handoff")
+ stopwatch?.stop(TAG)
+
stopRecordingTimer()
// Clear recording
@@ -363,7 +458,11 @@ class CameraScreenViewModel : ViewModel() {
*/
fun stopRecording() {
camera?.cameraControl?.enableTorch(false)
- recording?.stop()
+
+ val activeRecording = recording ?: return
+
+ recordingStopwatch = Stopwatch("recording-stop")
+ activeRecording.stop()
recording = null
}
@@ -386,6 +485,7 @@ class CameraScreenViewModel : ViewModel() {
cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, lastAttempt.preview, lastAttempt.imageCapture, videoCapture)
this.videoCapture = videoCapture
+ observeZoomRange()
Log.d(TAG, "Rebound with video capture for limited device")
videoCapture
} catch (e: Exception) {
@@ -411,6 +511,7 @@ class CameraScreenViewModel : ViewModel() {
cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, *attempt.toTypedArray())
videoCapture = attempt.videoCapture
+ observeZoomRange()
Log.d(TAG, "Rebound to last successful configuration after video capture")
} catch (e: Exception) {
Log.e(TAG, "Failed to rebind to last successful configuration after video capture", e)
@@ -420,6 +521,8 @@ class CameraScreenViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
stopRecording()
+ observedZoomState?.removeObserver(zoomRangeObserver)
+ observedZoomState = null
}
private fun handleBindCameraEvent(
@@ -489,6 +592,7 @@ class CameraScreenViewModel : ViewModel() {
imageCapture = attempt.imageCapture
videoCapture = attempt.videoCapture
captureMode = event.captureMode
+ observeZoomRange()
} catch (e: Exception) {
Log.e(TAG, "Use case binding failed (attempt ${index + 1} of ${bindingAttempts.size})", e)
continue
@@ -675,6 +779,9 @@ class CameraScreenViewModel : ViewModel() {
) {
val currentCamera = camera ?: return
+ // A pinch takes over any in-flight animation from wherever it has reached.
+ zoomAnimation?.cancel()
+
// Get current zoom ratio and calculate new zoom
val currentZoom = state.zoomRatio
val newZoom = (currentZoom * event.zoomFactor).coerceIn(
@@ -698,6 +805,9 @@ class CameraScreenViewModel : ViewModel() {
return
}
+ // The lens being animated is about to be unbound, and the one replacing it starts from its own zoom.
+ zoomAnimation?.cancel()
+
// Toggle between front and back camera
val newLensFacing = if (state.lensFacing == CameraSelector.LENS_FACING_BACK) {
CameraSelector.LENS_FACING_FRONT
@@ -717,28 +827,140 @@ class CameraScreenViewModel : ViewModel() {
imageCapture?.flashMode = flashMode.cameraxMode
}
+ /**
+ * Animates to a ratio, which is what the zoom bar asks for.
+ *
+ * The intermediate ratios are interpolated in log space, so doubling takes as long from 1x as it does from 8x;
+ * interpolated linearly the same journey would tear away at the start and crawl at the end.
+ *
+ * [recordingStartZoomRatio] moves with the animation, so a capture-button drag picked up midway carries on from where
+ * the lens has reached rather than from where the recording started.
+ */
+ private fun handleSetZoomRatioEvent(
+ state: CameraScreenState,
+ zoomRatio: Float
+ ) {
+ val currentCamera = camera ?: return
+ val zoomState = currentCamera.cameraInfo.zoomState.value
+
+ val clampedZoomRatio = zoomRatio.coerceIn(zoomState?.minZoomRatio ?: 1f, zoomState?.maxZoomRatio ?: 1f)
+ val fromZoomRatio = state.zoomRatio
+
+ zoomAnimation?.cancel()
+
+ // Nothing to interpolate: a non-positive ratio has no logarithm, and one already at the level has nowhere to go.
+ if (fromZoomRatio <= 0f || clampedZoomRatio <= 0f || fromZoomRatio == clampedZoomRatio) {
+ applyZoomRatio(currentCamera, clampedZoomRatio)
+ return
+ }
+
+ zoomAnimation = viewModelScope.launch {
+ val fromLog = ln(fromZoomRatio)
+ val toLog = ln(clampedZoomRatio)
+
+ // Counting frames rather than reading a wall clock keeps the duration stable under a test's virtual time.
+ var elapsedMs = 0L
+ while (elapsedMs < ZOOM_ANIMATION_DURATION_MS) {
+ delay(ZOOM_ANIMATION_FRAME_MS)
+ elapsedMs += ZOOM_ANIMATION_FRAME_MS
+
+ val fraction = ZOOM_ANIMATION_EASING.transform((elapsedMs.toFloat() / ZOOM_ANIMATION_DURATION_MS).coerceAtMost(1f))
+ applyZoomRatio(currentCamera, exp(fromLog + (toLog - fromLog) * fraction))
+ }
+
+ // Interpolating through a logarithm leaves the last frame beside the level rather than on it, and the bar reads
+ // the ratio to decide what is selected, so finish exactly on the level.
+ applyZoomRatio(currentCamera, clampedZoomRatio)
+ }
+ }
+
+ /** Sets the lens to [zoomRatio] and moves the drag base and state along with it. */
+ private fun applyZoomRatio(camera: Camera, zoomRatio: Float) {
+ camera.cameraControl.setZoomRatio(zoomRatio)
+ recordingStartZoomRatio = zoomRatio
+
+ _state.value = _state.value.copy(zoomRatio = zoomRatio)
+ }
+
+ /**
+ * Leaves a running recording going without the capture button being held. A lock with no recording behind it is
+ * dropped rather than held, so it cannot outlive the gesture that asked for it and apply to the next recording.
+ */
+ private fun handleLockRecordingEvent(state: CameraScreenState) {
+ when {
+ state.isRecording -> _state.value = state.copy(isRecordingLocked = true)
+ hasActiveRecording -> pendingRecordingLock = true
+ else -> Log.w(TAG, "Ignoring a lock with no recording to hold open")
+ }
+ }
+
+ /**
+ * Pauses the running recording, or resumes a paused one. The state is not written here: the recorder reports the pause
+ * taking hold and that is what the screen goes by, so a pause it will not honor never shows.
+ */
+ private fun handleToggleRecordingPausedEvent(state: CameraScreenState) {
+ val activeRecording = recording ?: return
+
+ if (state.isRecordingPaused) {
+ activeRecording.resume()
+ } else {
+ activeRecording.pause()
+ }
+ }
+
+ /**
+ * Observes what the bound lens can reach. A camera reports its zoom when it is ready rather than by the time it is
+ * bound, so this observes rather than taking a single reading — a lens whose range arrives late would otherwise look
+ * like one that cannot zoom.
+ */
+ private fun observeZoomRange() {
+ val zoomState = camera?.cameraInfo?.zoomState
+
+ if (zoomState === observedZoomState) {
+ return
+ }
+
+ observedZoomState?.removeObserver(zoomRangeObserver)
+ observedZoomState = zoomState
+ needsZoomResync = true
+
+ if (zoomState != null) {
+ zoomState.observeForever(zoomRangeObserver)
+ } else {
+ _state.value = _state.value.copy(zoomRange = DEFAULT_ZOOM_RANGE)
+ }
+ }
+
private fun handleSetLinearZoomEvent(
state: CameraScreenState,
linearZoom: Float
) {
val currentCamera = camera ?: return
+ // A drag takes over any in-flight animation from wherever it has reached.
+ zoomAnimation?.cancel()
+
// Clamp linear zoom to valid range (-1 to 1)
val clampedLinearZoom = linearZoom.coerceIn(-1f, 1f)
- // Use the zoom ratio from when recording started as the base, so that the
- // drag gesture is relative to the user's current zoom level rather than jumping.
- // Positive values (0 to 1) zoom in from base toward maxZoomRatio.
- // Negative values (-1 to 0) zoom out from base toward minZoomRatio.
- val baseZoom = recordingStartZoomRatio
- val minZoom = currentCamera.cameraInfo.zoomState.value?.minZoomRatio ?: 1f
- val maxZoom = currentCamera.cameraInfo.zoomState.value?.maxZoomRatio ?: 1f
- val newZoomRatio = if (clampedLinearZoom >= 0f) {
+ val zoomState = currentCamera.cameraInfo.zoomState.value
+ val minZoom = zoomState?.minZoomRatio ?: 1f
+ val maxZoom = zoomState?.maxZoomRatio ?: 1f
+
+ // The drag runs from the ratio it started at rather than from 1x, so picking it up does not jump the lens. Positive
+ // values zoom in from that base toward maxZoom, negative values out toward minZoom. The base is clamped as well as
+ // the result: a base the lens can no longer reach would otherwise leave the whole drag pinned to one end.
+ val baseZoom = recordingStartZoomRatio.coerceIn(minZoom, maxZoom)
+ val targetZoomRatio = if (clampedLinearZoom >= 0f) {
baseZoom + (maxZoom - baseZoom) * clampedLinearZoom
} else {
baseZoom + (baseZoom - minZoom) * clampedLinearZoom
}
+ // The camera clamps what it is sent, so the state is clamped the same way — otherwise the zoom bar reads a level the
+ // lens is not at, and the next drag works from a base the lens never reached.
+ val newZoomRatio = targetZoomRatio.coerceIn(minZoom, maxZoom)
+
currentCamera.cameraControl.setZoomRatio(newZoomRatio)
_state.value = state.copy(zoomRatio = newZoomRatio)
@@ -758,19 +980,35 @@ class CameraScreenViewModel : ViewModel() {
_state.value = state.copy(captureError = null)
}
- private fun startRecordingTimer() {
- _state.value = _state.value.copy(isRecording = true, recordingDuration = 0L)
+ private fun startRecordingTimer(isRecordingLocked: Boolean) {
+ _state.value = _state.value.copy(
+ isRecording = true,
+ isRecordingLocked = isRecordingLocked || pendingRecordingLock,
+ isRecordingPaused = false,
+ recordingDuration = 0L
+ )
+ pendingRecordingLock = false
viewModelScope.launch {
while (_state.value.isRecording) {
delay(100L)
- _state.value = _state.value.copy(recordingDuration = _state.value.recordingDuration + 100L)
+
+ // A paused recording is not recording anything, so its duration has nothing to add.
+ if (!_state.value.isRecordingPaused) {
+ _state.value = _state.value.copy(recordingDuration = _state.value.recordingDuration + 100L)
+ }
}
}
}
private fun stopRecordingTimer() {
- _state.value = _state.value.copy(isRecording = false, recordingDuration = 0L)
+ pendingRecordingLock = false
+ _state.value = _state.value.copy(
+ isRecording = false,
+ isRecordingLocked = false,
+ isRecordingPaused = false,
+ recordingDuration = 0L
+ )
}
@androidx.annotation.OptIn(markerClass = [androidx.camera.core.ExperimentalGetImage::class])
diff --git a/feature/camera/src/main/java/org/signal/camera/hud/CameraHudMotion.kt b/feature/camera/src/main/java/org/signal/camera/hud/CameraHudMotion.kt
new file mode 100644
index 0000000000..35d4befeaf
--- /dev/null
+++ b/feature/camera/src/main/java/org/signal/camera/hud/CameraHudMotion.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.togetherWith
+
+/**
+ * How one control gives way to another in place: whichever is arriving scales up as it fades in, and whichever is
+ * leaving scales down as it fades out.
+ *
+ * Shared so that the corner beside the capture button swapping the lock for the pause, and the pause swapping its own
+ * icon for the play, read as one movement rather than two that nearly match.
+ */
+internal object CameraHudMotion {
+
+ const val SWAP_DURATION_MS = 200
+ const val SWAP_SCALE = 0.92f
+
+ val swap: ContentTransform
+ get() {
+ val spec = tween(SWAP_DURATION_MS)
+
+ return (scaleIn(initialScale = SWAP_SCALE, animationSpec = spec) + fadeIn(animationSpec = spec))
+ .togetherWith(scaleOut(targetScale = SWAP_SCALE, animationSpec = spec) + fadeOut(animationSpec = spec))
+ }
+}
diff --git a/feature/camera/src/main/java/org/signal/camera/hud/CaptureButton.kt b/feature/camera/src/main/java/org/signal/camera/hud/CaptureButton.kt
index 0436ea111c..4c3aea44c9 100644
--- a/feature/camera/src/main/java/org/signal/camera/hud/CaptureButton.kt
+++ b/feature/camera/src/main/java/org/signal/camera/hud/CaptureButton.kt
@@ -5,80 +5,98 @@
package org.signal.camera.hud
-import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
-import androidx.compose.foundation.Canvas
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.waitForUpOrCancellation
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.graphics.StrokeCap
-import androidx.compose.ui.graphics.drawscope.DrawScope
-import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import kotlin.math.min
+import androidx.compose.ui.unit.lerp
+import org.signal.camera.test.TestTags
-/**
- * Capture button colors matching CameraButtonView.java
- */
private object CaptureButtonColors {
- /** Background fill: custom control color (0xCC333333) */
val Background = Color(0xCC333333)
-
- /** Inner fill: pure white (0xFFFFFFFF) */
val CaptureFill = Color.White
-
- /** Outer stroke while recording: black at 15% alpha (0x26000000) */
- val Outline = Color(0x26000000)
-
- /** Record indicator: Camera control red (0xFFD92F20) */
val Record = Color(0xFFD92F20)
-
- /** Progress arc: pure white (0xFFFFFFFF) */
- val Progress = Color.White
}
-/**
- * Stroke widths matching CameraButtonView.java
- */
-private object CaptureButtonDimensions {
- /** Stroke width for the capture arc in image mode: 3.5dp */
- val CaptureArcStrokeWidth = 0.dp
+/** Every measurement the button is drawn from, so there is one place to change any of them. */
+internal object CaptureButtonDimensions {
+ /** The outer circle, which every state shares. */
+ val ButtonSize = 76.dp
- /** Stroke width for the outline in video mode: 4dp */
- val OutlineStrokeWidth = 0.dp
+ /** The inner shape while the button is idle, in either mode. */
+ val IdleSize = 64.dp
- /** Stroke width for the progress arc: 4dp */
- val ProgressArcStrokeWidth = 4.dp
+ /** What the inner shape shrinks to under a finger. */
+ val PressedSize = 56.dp
- /** Protection margin for capture fill circle: 10dp */
- val CaptureFillProtection = 6.dp
+ /** A held recording stays at the size the press that started it left the shape at. */
+ val HeldRecordingSize = PressedSize
- /** Default button size */
- val DefaultButtonSize = 76.dp
+ val LockedRecordingSize = 44.dp
+ val LockedRecordingCornerRadius = 10.dp
- /** Default image capture size (inner area) */
- val DefaultImageCaptureSize = 60.dp
+ /** The circle dragged to the lock, which is the lock button's own size. */
+ val LockDraggableSize = RecordingActionButtonSize
- /** Default record indicator size (red dot) */
- val DefaultRecordSize = 40.dp
+ /**
+ * How far past the lock's own edge still counts as being over it. The thumb covers the lock on the way there, so a
+ * target no bigger than the button itself is hard to feel for.
+ */
+ val LockSnapMargin = 20.dp
+
+ /**
+ * How much further out than [LockSnapMargin] the finger has to come back before the lock releases. Without the
+ * hysteresis the lock chatters on and off as a thumb wavers on the boundary.
+ */
+ val LockSnapRelease = 12.dp
+
+ /**
+ * How far a drag has to have carried toward the lock before it counts as headed there. Below it the drag still belongs
+ * to the zoom, so a vertical zoom drag that wanders a pixel sideways does not lose it.
+ */
+ val LockDragSlop = 8.dp
+
+ /** The press shrinks by scaling, so the two sizes above stay the only place either number is written down. */
+ val PressedScale = PressedSize / IdleSize
}
/**
@@ -93,161 +111,291 @@ private const val DRAG_DISTANCE_MULTIPLIER = 3
*/
private const val DEADZONE_REDUCTION_PERCENT = 0.35f
+/** Played when the lock takes hold, which the thumb covering it is otherwise no way to know. */
+private val LockSnapHaptic = HapticFeedbackType.SegmentTick
+
/**
- * A capture button that supports both photo capture (tap) and video recording (long press).
- *
- * This composable mimics the behavior and appearance of [CameraButtonView] from the legacy
- * camera implementation. It displays:
- * - In idle state: A white-filled circle with a white arc outline
- * - In recording state: A larger circle with a red recording indicator and progress arc
- *
- * @param modifier Modifier to be applied to the button
- * @param isRecording Whether video recording is currently active
- * @param recordingProgress Progress of the recording from 0f to 1f (for progress arc display)
- * @param imageCaptureSize Size of the inner capture circle in image mode
- * @param recordSize Size of the red recording indicator circle
- * @param onTap Callback for tap gesture (photo capture)
- * @param onLongPressStart Callback when long press begins (video recording start)
- * @param onLongPressEnd Callback when long press ends (video recording stop)
- * @param onZoomChange Callback for zoom level changes during recording (0f to 1f)
+ * Played when the lock is taken. [HapticFeedbackType.GestureThresholdActivate] is what this is in name, but below API 34
+ * it falls back to a context click, which is too faint to feel through a thumb that is mid-drag.
*/
-@Composable
-fun CaptureButton(
- modifier: Modifier = Modifier,
- isRecording: Boolean,
- recordingProgress: Float = 0f,
- imageCaptureSize: Dp = CaptureButtonDimensions.DefaultImageCaptureSize,
- recordSize: Dp = CaptureButtonDimensions.DefaultRecordSize,
- onTap: () -> Unit,
- onLongPressStart: () -> Unit,
- onLongPressEnd: () -> Unit,
- onZoomChange: (Float) -> Unit
-) {
- var isPressed by remember { mutableStateOf(false) }
- val scale = remember { Animatable(1f) }
+private val LockHaptic = HapticFeedbackType.LongPress
- // Scale animation for press feedback and recording state
- LaunchedEffect(isPressed, isRecording) {
- val targetScale = when {
- isRecording -> 1.42f
- isPressed -> 0.9f
- else -> 1f
- }
+/** Size and corner radius share a spec so a circle stays circular on the way to a square. */
+private val ShapeSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium)
+private val ColorSpec = tween(durationMillis = 200)
+private val PressSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium)
- scale.animateTo(
- targetValue = targetScale,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioMediumBouncy,
- stiffness = if (isRecording) Spring.StiffnessLow else Spring.StiffnessMedium
- )
+/** Slacker than [ShapeSpec] so the inner shape trails the finger on the way to the lock rather than tracking it. */
+private val LockDragSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
+
+/** Everything the button animates between [CaptureButtonState]s. */
+private data class CaptureButtonInnerShape(val size: Dp, val cornerRadius: Dp, val color: Color)
+
+private val CaptureButtonState.innerShape: CaptureButtonInnerShape
+ get() = when (this) {
+ CaptureButtonState.PHOTO -> CaptureButtonInnerShape(
+ size = CaptureButtonDimensions.IdleSize,
+ cornerRadius = CaptureButtonDimensions.IdleSize / 2,
+ color = CaptureButtonColors.CaptureFill
+ )
+
+ CaptureButtonState.VIDEO -> CaptureButtonInnerShape(
+ size = CaptureButtonDimensions.IdleSize,
+ cornerRadius = CaptureButtonDimensions.IdleSize / 2,
+ color = CaptureButtonColors.Record
+ )
+
+ CaptureButtonState.RECORDING_HELD -> CaptureButtonInnerShape(
+ size = CaptureButtonDimensions.HeldRecordingSize,
+ cornerRadius = CaptureButtonDimensions.HeldRecordingSize / 2,
+ color = CaptureButtonColors.Record
+ )
+
+ CaptureButtonState.RECORDING_LOCKED -> CaptureButtonInnerShape(
+ size = CaptureButtonDimensions.LockedRecordingSize,
+ cornerRadius = CaptureButtonDimensions.LockedRecordingCornerRadius,
+ color = CaptureButtonColors.Record
)
}
- val density = LocalDensity.current
- val recordRadius = with(density) { recordSize.toPx() / 2f }
- val outlineStroke = with(density) { CaptureButtonDimensions.OutlineStrokeWidth.toPx() }
- val progressStroke = with(density) { CaptureButtonDimensions.ProgressArcStrokeWidth.toPx() }
- val fillProtection = with(density) { CaptureButtonDimensions.CaptureFillProtection.toPx() }
+/**
+ * A capture button that supports both photo capture (tap) and video recording (long press).
+ *
+ * The outer circle is fixed; the inner shape animates its size, corners and color between the [CaptureButtonState]s: a
+ * white circle for a photo, a red one for a recording waiting to start, a small red circle while a held recording runs,
+ * and a red rounded square while a locked one runs.
+ *
+ * @param state Which of the button's looks to show, from [CaptureButtonState.of]
+ * @param onTap Callback for tap gesture, whose meaning is the caller's to decide from [state]
+ * @param onLongPressStart Callback when long press begins (video recording start)
+ * @param onLongPressEnd Callback when long press ends (video recording stop)
+ * @param onZoomChange Callback for zoom level changes during recording (0f to 1f)
+ * @param onLock Callback when a drag has reached the lock, asking for the recording to run unheld
+ * @param lockOffset Where the lock sits relative to this button's center, in pixels of this button's own frame.
+ * [Offset.Zero] for a recording that has no lock to be dragged to.
+ * @param modifier Modifier to be applied to the button
+ */
+@Composable
+fun CaptureButton(
+ state: CaptureButtonState,
+ onTap: () -> Unit,
+ onLongPressStart: () -> Unit,
+ onLongPressEnd: () -> Unit,
+ onZoomChange: (Float) -> Unit,
+ onLock: () -> Unit = {},
+ lockOffset: Offset = Offset.Zero,
+ modifier: Modifier = Modifier
+) {
+ var isPressed by remember { mutableStateOf(false) }
+
+ /** How far the drag has carried toward the lock, from nothing to the whole way. */
+ var lockProgress by remember { mutableFloatStateOf(0f) }
+
+ val haptics = LocalHapticFeedback.current
+
+ // The gesture detector is set up once and never restarted, so the callbacks are read through here rather than
+ // captured — a captured callback would answer for the state at first composition.
+ val currentOnTap by rememberUpdatedState(onTap)
+ val currentOnLongPressStart by rememberUpdatedState(onLongPressStart)
+ val currentOnLongPressEnd by rememberUpdatedState(onLongPressEnd)
+ val currentOnZoomChange by rememberUpdatedState(onZoomChange)
+ val currentOnLock by rememberUpdatedState(onLock)
+ val currentLockOffset by rememberUpdatedState(lockOffset)
+
+ // A drag toward the lock takes the shape part of the way to what it will be once it gets there, so the button shows
+ // what letting go would leave behind before the finger commits to it.
+ val isDraggingToLock = lockProgress > 0f && state == CaptureButtonState.RECORDING_HELD
+ val innerShape = if (isDraggingToLock) {
+ val held = CaptureButtonState.RECORDING_HELD.innerShape
+ val locked = CaptureButtonState.RECORDING_LOCKED.innerShape
+
+ // Both ends of the drag are the recording red, so only the size and the corners have anywhere to go.
+ CaptureButtonInnerShape(
+ size = lerp(held.size, locked.size, lockProgress),
+ cornerRadius = lerp(held.cornerRadius, locked.cornerRadius, lockProgress),
+ color = locked.color
+ )
+ } else {
+ state.innerShape
+ }
+
+ val shapeSpec = if (isDraggingToLock) LockDragSpec else ShapeSpec
+ val innerSize by animateDpAsState(targetValue = innerShape.size, animationSpec = shapeSpec, label = "CaptureButtonSize")
+ val innerCornerRadius by animateDpAsState(targetValue = innerShape.cornerRadius, animationSpec = shapeSpec, label = "CaptureButtonCornerRadius")
+ val innerColor by animateColorAsState(targetValue = innerShape.color, animationSpec = ColorSpec, label = "CaptureButtonColor")
+ // The press shrinks the idle circle to the size a held recording runs at, so a hold that becomes one does not move
+ // again. A recording carries that size itself, so the scale lifts as it starts rather than shrinking twice.
+ val pressedScale by animateFloatAsState(
+ targetValue = if (isPressed && !state.isRecording) CaptureButtonDimensions.PressedScale else 1f,
+ animationSpec = PressSpec,
+ label = "CaptureButtonPressedScale"
+ )
Box(
modifier = modifier
- .size(CaptureButtonDimensions.DefaultButtonSize)
- .graphicsLayer {
- scaleX = scale.value
- scaleY = scale.value
- }
+ .size(CaptureButtonDimensions.ButtonSize)
+ .testTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON)
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
isPressed = true
- var longPressTriggered = false
- var startY = down.position.y
- val pressStartTime = System.currentTimeMillis()
- val longPressTimeoutMs = viewConfiguration.longPressTimeoutMillis
-
- // Calculate deadzone for zoom gestures
val deadzoneTop = size.height * DEADZONE_REDUCTION_PERCENT / 2f
+ val deadzoneBottom = size.height * (1f - DEADZONE_REDUCTION_PERCENT / 2f)
val maxRange = size.height * DRAG_DISTANCE_MULTIPLIER
+ val buttonCenter = Offset(size.width / 2f, size.height / 2f)
+ val lockRadius = CaptureButtonDimensions.LockDraggableSize.toPx() / 2f
+ val snapTo = lockRadius + CaptureButtonDimensions.LockSnapMargin.toPx()
+ val snapOff = snapTo + CaptureButtonDimensions.LockSnapRelease.toPx()
+ val lockDragSlop = CaptureButtonDimensions.LockDragSlop.toPx()
+
+ // The lock takes hold further out than its own edge and does not release until the finger is further out
+ // still, so it snaps on rather than having to be held on. Offset.Zero means no lock is on offer, which would
+ // otherwise resolve to the button's own center.
+ fun isOverLock(position: Offset, wasOver: Boolean): Boolean {
+ val offset = currentLockOffset
+
+ if (offset == Offset.Zero) {
+ return false
+ }
+
+ return (position - (buttonCenter + offset)).getDistance() <= if (wasOver) snapOff else snapTo
+ }
+
try {
- while (true) {
- val event = withTimeoutOrNull(50) { awaitPointerEvent() }
+ // The press becomes a hold when the timeout expires rather than when the finger does anything, so it
+ // arrives as the cancellation of the wait for a lift.
+ var isHeld = false
+ val liftedEarly = try {
+ withTimeout(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation() }
+ } catch (_: PointerEventTimeoutCancellationException) {
+ isHeld = true
+ null
+ }
- if (event != null) {
- val currentPointer = event.changes.firstOrNull { it.id == down.id }
-
- if (currentPointer == null || !currentPointer.pressed) {
- // Finger lifted
- if (!longPressTriggered) {
- onTap()
- } else {
- onLongPressEnd()
- }
- break
- }
-
- // Check for long press timeout
- val elapsed = System.currentTimeMillis() - pressStartTime
- if (!longPressTriggered && elapsed >= longPressTimeoutMs) {
- longPressTriggered = true
- startY = currentPointer.position.y
- onLongPressStart()
- }
-
- // Handle zoom during recording
- if (longPressTriggered) {
- val deadzoneBottom = size.height * (1f - DEADZONE_REDUCTION_PERCENT / 2f)
- val isAboveDeadzone = currentPointer.position.y < deadzoneTop
- val isBelowDeadzone = currentPointer.position.y > deadzoneBottom
- if (isAboveDeadzone) {
- val deltaY = (deadzoneTop - currentPointer.position.y).coerceAtLeast(0f)
- val zoomPercent = (deltaY / maxRange).coerceIn(0f, 1f)
- val interpolatedZoom = decelerateInterpolation(zoomPercent)
- onZoomChange(interpolatedZoom)
- } else if (isBelowDeadzone) {
- val deltaY = (currentPointer.position.y - deadzoneBottom).coerceAtLeast(0f)
- val zoomPercent = (deltaY / maxRange).coerceIn(0f, 1f)
- val interpolatedZoom = decelerateInterpolation(zoomPercent)
- onZoomChange(-interpolatedZoom)
- }
- }
-
- currentPointer.consume()
- } else {
- // Timeout - check for long press
- val elapsed = System.currentTimeMillis() - pressStartTime
- if (!longPressTriggered && elapsed >= longPressTimeoutMs) {
- longPressTriggered = true
- startY = down.position.y
- onLongPressStart()
- }
+ when {
+ isHeld -> Unit
+ // A press let go of before the timeout is a tap. Anything else is the gesture being cancelled.
+ liftedEarly != null -> {
+ currentOnTap()
+ return@awaitEachGesture
}
+
+ else -> return@awaitEachGesture
+ }
+
+ currentOnLongPressStart()
+
+ // Nothing waits with a timeout from here on. An event that arrived while one was expiring would be dropped,
+ // and with a finger holding still the only event of the whole gesture is the lift that ends it.
+
+ // Whether the finger was over the lock when it was last seen, which is what the lift is judged against.
+ var overLock = false
+
+ while (true) {
+ val pointer = awaitPointerEvent().changes.firstOrNull { it.id == down.id } ?: break
+
+ val wasOverLock = overLock
+ overLock = isOverLock(pointer.position, wasOverLock)
+
+ // Taking hold is felt as it happens, so the finger knows it has arrived without having to commit to find
+ // out. Only the crossing plays, not every event that follows it.
+ if (overLock && !wasOverLock) {
+ haptics.performHapticFeedback(LockSnapHaptic)
+ }
+
+ if (!pointer.pressed) {
+ break
+ }
+
+ // A drag is only headed for the lock once it has carried past the slop; below that it still belongs to
+ // the zoom, so a vertical drag that wanders a pixel sideways does not silently lose it. Being over the
+ // lock finishes the morph however far short the travel counts, so what the button shows agrees with what
+ // letting go would do.
+ val lockTravel = currentLockOffset.fractionTraveled(pointer.position - down.position)
+ val isHeadedForLock = overLock || lockTravel * currentLockOffset.getDistance() > lockDragSlop
+
+ lockProgress = when {
+ overLock -> 1f
+ isHeadedForLock -> lockTravel
+ else -> 0f
+ }
+
+ val zoom = when {
+ isHeadedForLock -> null
+ pointer.position.y < deadzoneTop -> decelerateInterpolation(((deadzoneTop - pointer.position.y) / maxRange).coerceIn(0f, 1f))
+ pointer.position.y > deadzoneBottom -> -decelerateInterpolation(((pointer.position.y - deadzoneBottom) / maxRange).coerceIn(0f, 1f))
+ else -> null
+ }
+
+ if (zoom != null) {
+ currentOnZoomChange(zoom)
+ }
+
+ pointer.consume()
+ }
+
+ // Reaching the lock is not what takes it: the finger has to come off over it. A drag that crosses the lock
+ // on its way elsewhere, or that backs off it before lifting, leaves the recording as it was.
+ if (overLock) {
+ haptics.performHapticFeedback(LockHaptic)
+ currentOnLock()
+ } else {
+ currentOnLongPressEnd()
}
} finally {
isPressed = false
+ lockProgress = 0f
}
}
},
contentAlignment = Alignment.Center
) {
- Canvas(modifier = Modifier.matchParentSize()) {
- if (isRecording) {
- drawForVideoCapture(
- recordRadius = recordRadius,
- outlineStroke = outlineStroke,
- progressStroke = progressStroke,
- progressPercent = recordingProgress
- )
- } else {
- drawForImageCapture(
- fillProtection = fillProtection
- )
- }
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(color = CaptureButtonColors.Background, shape = CircleShape)
+ )
+
+ Box(
+ modifier = Modifier
+ .size(innerSize)
+ .graphicsLayer {
+ scaleX = pressedScale
+ scaleY = pressedScale
+ }
+ .background(color = innerColor, shape = RoundedCornerShape(innerCornerRadius))
+ )
+
+ // The circle the finger carries to the lock. It runs on the line between the two rather than following the finger
+ // exactly, so a drag that wanders still arrives, and it is not sprung — only the shape it leaves behind is.
+ if (isDraggingToLock) {
+ Box(
+ modifier = Modifier
+ .size(CaptureButtonDimensions.LockDraggableSize)
+ .graphicsLayer {
+ translationX = currentLockOffset.x * lockProgress
+ translationY = currentLockOffset.y * lockProgress
+ }
+ .background(color = CaptureButtonColors.Record, shape = CircleShape)
+ )
}
}
}
+/**
+ * How far toward this offset a [drag] has carried, as a fraction of the whole distance. Only the component along the way
+ * there counts, so a drag across it gets no closer and one past it goes no further.
+ *
+ * Zero for [Offset.Zero], which is what a recording with no lock on offer has.
+ */
+private fun Offset.fractionTraveled(drag: Offset): Float {
+ val distanceSquared = x * x + y * y
+
+ return if (distanceSquared > 0f) ((drag.x * x + drag.y * y) / distanceSquared).coerceIn(0f, 1f) else 0f
+}
+
/**
* Decelerate interpolation matching DecelerateInterpolator from Android.
* Formula: 1.0 - (1.0 - input)^2
@@ -256,121 +404,64 @@ private fun decelerateInterpolation(input: Float): Float {
return 1f - (1f - input) * (1f - input)
}
-/**
- * Draw the button in image capture mode.
- */
-private fun DrawScope.drawForImageCapture(
- fillProtection: Float
-) {
- val centerX = size.width / 2f
- val centerY = size.height / 2f
- val radius = min(centerX, centerY)
-
- // Background circle
- drawCircle(
- color = CaptureButtonColors.Background,
- radius = radius,
- center = Offset(centerX, centerY)
- )
-
- // Inner fill circle (smaller to create the ring effect)
- drawCircle(
- color = CaptureButtonColors.CaptureFill,
- radius = radius - fillProtection,
- center = Offset(centerX, centerY)
- )
+@Preview(name = "Every state", showBackground = true, backgroundColor = 0xFF444444)
+@Composable
+private fun CaptureButtonStatesPreview() {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CaptureButtonState.entries.forEach { state ->
+ CaptureButton(
+ state = state,
+ onTap = {},
+ onLongPressStart = {},
+ onLongPressEnd = {},
+ onZoomChange = {}
+ )
+ }
+ }
}
/**
- * Draw the button in video capture mode.
+ * The animations between the states, for the interactive preview: a tap steps to the next state and a hold runs the held
+ * recording for as long as it is held, matching what the camera does with the same gestures.
+ *
+ * The canvas is given a fixed size, and the state's name a whole line of it, so a longer name cannot widen the canvas
+ * out from under the button and leave it stretched.
*/
-private fun DrawScope.drawForVideoCapture(
- recordRadius: Float,
- outlineStroke: Float,
- progressStroke: Float,
- progressPercent: Float
-) {
- val centerX = size.width / 2f
- val centerY = size.height / 2f
- val radius = min(centerX, centerY)
-
- // Background circle
- drawCircle(
- color = CaptureButtonColors.Background,
- radius = radius,
- center = Offset(centerX, centerY)
- )
-
- // Outline stroke
- drawCircle(
- color = CaptureButtonColors.Outline,
- radius = radius,
- center = Offset(centerX, centerY),
- style = Stroke(width = outlineStroke)
- )
-
- // Red record indicator
- drawCircle(
- color = CaptureButtonColors.Record,
- radius = recordRadius,
- center = Offset(centerX, centerY)
- )
-
- // Progress arc (only if there's progress to show)
- if (progressPercent > 0f) {
- val strokeHalf = progressStroke / 2f
- drawArc(
- color = CaptureButtonColors.Progress,
- startAngle = -90f, // Start from top
- sweepAngle = 360f * progressPercent,
- useCenter = false,
- topLeft = Offset(strokeHalf, strokeHalf),
- size = Size(size.width - progressStroke, size.height - progressStroke),
- style = Stroke(width = progressStroke, cap = StrokeCap.Round)
- )
- }
-}
-
-@Preview(name = "Idle State", showBackground = true, backgroundColor = 0xFF444444)
+@Preview(name = "Animated between states", showBackground = true, backgroundColor = 0xFF444444, widthDp = 240, heightDp = 220)
@Composable
-private fun CaptureButtonIdlePreview() {
- Box(modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center) {
+private fun CaptureButtonInteractivePreview() {
+ var stateIndex by remember { mutableIntStateOf(0) }
+ var heldState: CaptureButtonState? by remember { mutableStateOf(null) }
+
+ val steppedState = CaptureButtonState.entries[stateIndex % CaptureButtonState.entries.size]
+ val state = heldState ?: steppedState
+
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Text(
+ text = state.name,
+ color = Color.White,
+ maxLines = 1,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
CaptureButton(
- isRecording = false,
- onTap = {},
- onLongPressStart = {},
- onLongPressEnd = {},
+ state = state,
+ onTap = { stateIndex++ },
+ onLongPressStart = { heldState = CaptureButtonState.RECORDING_HELD },
+ onLongPressEnd = { heldState = null },
onZoomChange = {}
)
- }
-}
-@Preview(name = "Recording State", showBackground = true, backgroundColor = 0xFF444444)
-@Composable
-private fun CaptureButtonRecordingPreview() {
- Box(modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center) {
- CaptureButton(
- isRecording = true,
- recordingProgress = 0f,
- onTap = {},
- onLongPressStart = {},
- onLongPressEnd = {},
- onZoomChange = {}
- )
- }
-}
-
-@Preview(name = "Recording with Progress", showBackground = true, backgroundColor = 0xFF444444)
-@Composable
-private fun CaptureButtonRecordingWithProgressPreview() {
- Box(modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center) {
- CaptureButton(
- isRecording = true,
- recordingProgress = 0.65f,
- onTap = {},
- onLongPressStart = {},
- onLongPressEnd = {},
- onZoomChange = {}
- )
+ Button(onClick = { stateIndex++ }) {
+ Text(text = "Next state")
+ }
}
}
diff --git a/feature/camera/src/main/java/org/signal/camera/hud/CaptureButtonState.kt b/feature/camera/src/main/java/org/signal/camera/hud/CaptureButtonState.kt
new file mode 100644
index 0000000000..e75bdfe460
--- /dev/null
+++ b/feature/camera/src/main/java/org/signal/camera/hud/CaptureButtonState.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+/** Which kind of capture the button offers while it is waiting to be used. */
+enum class CaptureButtonMode {
+ PHOTO,
+ VIDEO
+}
+
+/**
+ * Every look the capture button has, and all it needs to draw and animate itself: a caller says what is true of the
+ * camera and [of] turns that into the one state to show. [tapRequest] is what a tap on each of them asks for.
+ */
+enum class CaptureButtonState {
+ /** Waiting to take a photo. */
+ PHOTO,
+
+ /** Waiting to record, which is what the button will do instead of taking a photo. */
+ VIDEO,
+
+ /** Recording for only as long as the button is held. */
+ RECORDING_HELD,
+
+ /** Recording without being held, which runs until it is stopped. */
+ RECORDING_LOCKED;
+
+ /** Whether a recording is running, however it was started. */
+ val isRecording: Boolean
+ get() = this == RECORDING_HELD || this == RECORDING_LOCKED
+
+ companion object {
+
+ /** A running recording is what the button shows whatever mode the camera is in. */
+ fun of(
+ captureButtonMode: CaptureButtonMode,
+ isRecording: Boolean,
+ isRecordingLocked: Boolean
+ ): CaptureButtonState = when {
+ isRecording && isRecordingLocked -> RECORDING_LOCKED
+ isRecording -> RECORDING_HELD
+ captureButtonMode == CaptureButtonMode.VIDEO -> VIDEO
+ else -> PHOTO
+ }
+ }
+}
+
+/**
+ * What stands where the gallery button does. A recording has more use for that corner: while one is held it offers the
+ * lock that would leave it running, and once it is running unheld it offers to pause it.
+ */
+enum class GallerySlotContent {
+ GALLERY,
+ LOCK,
+ PAUSE;
+
+ companion object {
+ fun of(captureButtonState: CaptureButtonState): GallerySlotContent = when (captureButtonState) {
+ CaptureButtonState.RECORDING_HELD -> LOCK
+ CaptureButtonState.RECORDING_LOCKED -> PAUSE
+ CaptureButtonState.PHOTO, CaptureButtonState.VIDEO -> GALLERY
+ }
+ }
+}
+
+/**
+ * What a tap asks the camera for, decided by what the button is showing rather than by the gesture: a photo in photo
+ * mode, and in video mode a recording that runs without being held, which a second tap then stops.
+ *
+ * Null while a held recording runs, since the finger holding it is the only thing that ends it.
+ */
+val CaptureButtonState.tapRequest: StandardCameraHudEvents?
+ get() = when (this) {
+ CaptureButtonState.PHOTO -> StandardCameraHudEvents.PhotoCaptureTriggered
+ CaptureButtonState.VIDEO -> StandardCameraHudEvents.VideoCaptureStarted(isLocked = true)
+ CaptureButtonState.RECORDING_LOCKED -> StandardCameraHudEvents.VideoCaptureStopped
+ CaptureButtonState.RECORDING_HELD -> null
+ }
diff --git a/feature/camera/src/main/java/org/signal/camera/hud/GalleryThumbnailButton.kt b/feature/camera/src/main/java/org/signal/camera/hud/GalleryThumbnailButton.kt
index e25f501b26..09b6f2fc8e 100644
--- a/feature/camera/src/main/java/org/signal/camera/hud/GalleryThumbnailButton.kt
+++ b/feature/camera/src/main/java/org/signal/camera/hud/GalleryThumbnailButton.kt
@@ -28,12 +28,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.camera.R
+import org.signal.camera.test.TestTags
import org.signal.core.ui.compose.NightPreview
import org.signal.core.ui.compose.Previews
import org.signal.glide.compose.GlideImage
@@ -44,11 +46,13 @@ import org.signal.glide.compose.GlideImageScaleType
* Shows a circular thumbnail with a white border that opens the gallery when clicked.
*
* @param modifier Modifier to apply to the button
+ * @param enabled Whether the button can be used, which it cannot while a recording runs
* @param onClick Callback when the button is clicked
*/
@Composable
fun GalleryThumbnailButton(
modifier: Modifier = Modifier,
+ enabled: Boolean = true,
onClick: () -> Unit
) {
val context = LocalContext.current
@@ -64,7 +68,8 @@ fun GalleryThumbnailButton(
.size(52.dp)
.clip(CircleShape)
.background(colorResource(R.color.CameraHud_control_background), CircleShape)
- .clickable(onClick = onClick),
+ .clickable(enabled = enabled, onClick = onClick)
+ .testTag(TestTags.CAMERA_HUD_GALLERY_BUTTON),
contentAlignment = Alignment.Center
) {
if (thumbnailUri != null) {
diff --git a/feature/camera/src/main/java/org/signal/camera/hud/RecordingActionButtons.kt b/feature/camera/src/main/java/org/signal/camera/hud/RecordingActionButtons.kt
new file mode 100644
index 0000000000..35148bf3e2
--- /dev/null
+++ b/feature/camera/src/main/java/org/signal/camera/hud/RecordingActionButtons.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.unit.dp
+import org.signal.camera.R
+import org.signal.camera.test.TestTags
+import org.signal.core.ui.compose.NightPreview
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.SignalIcons
+
+/**
+ * Matches [GalleryThumbnailButton], whose place these take while a recording runs. The capture button reads it too: the
+ * circle it carries to the lock is this size, and so is how near the lock counts as over it.
+ */
+internal val RecordingActionButtonSize = 52.dp
+
+private val ActionIconSize = 24.dp
+
+/**
+ * Offers to leave a recording running without the capture button being held. It takes the gallery button's place while a
+ * recording is held, which puts it within reach of the finger already on the capture button.
+ *
+ * There is nothing to tap: sliding onto it is what takes the offer up.
+ */
+@Composable
+fun RecordingLockButton(modifier: Modifier = Modifier) {
+ RecordingActionButton(modifier = modifier.testTag(TestTags.CAMERA_HUD_LOCK_BUTTON)) {
+ Icon(
+ imageVector = SignalIcons.Lock.imageVector,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(ActionIconSize)
+ )
+ }
+}
+
+/**
+ * Takes the lock's place once a recording is running without being held, and offers to resume once it has been used.
+ *
+ * @param isPaused What the recorder reports rather than what was last asked of it, so the button cannot offer to undo a
+ * pause that never took.
+ */
+@Composable
+fun RecordingPauseButton(
+ isPaused: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ RecordingActionButton(
+ onClick = onClick,
+ modifier = modifier.testTag(TestTags.CAMERA_HUD_PAUSE_BUTTON)
+ ) {
+ // The same swap the corner makes when the lock gives way to this button, so the two read as one movement.
+ AnimatedContent(
+ targetState = isPaused,
+ transitionSpec = { CameraHudMotion.swap },
+ label = "RecordingPaused"
+ ) { paused ->
+ Icon(
+ imageVector = if (paused) SignalIcons.Play.imageVector else SignalIcons.Pause.imageVector,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(ActionIconSize)
+ )
+ }
+ }
+}
+
+/**
+ * The circle these buttons share with the gallery button, so one can stand in for another in place.
+ *
+ * A button that can be pressed is clipped to the circle first, so its press indication stays inside the circle rather
+ * than filling the square it is drawn in.
+ */
+@Composable
+private fun RecordingActionButton(
+ modifier: Modifier = Modifier,
+ onClick: (() -> Unit)? = null,
+ content: @Composable () -> Unit
+) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .size(RecordingActionButtonSize)
+ .clip(CircleShape)
+ .background(colorResource(R.color.CameraHud_control_background), CircleShape)
+ .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+ ) {
+ content()
+ }
+}
+
+@NightPreview
+@Composable
+private fun RecordingLockButtonPreview() {
+ Previews.Preview {
+ RecordingLockButton()
+ }
+}
+
+@NightPreview
+@Composable
+private fun RecordingPauseButtonPreview() {
+ Previews.Preview {
+ RecordingPauseButton(isPaused = false, onClick = {})
+ }
+}
+
+@NightPreview
+@Composable
+private fun RecordingResumeButtonPreview() {
+ Previews.Preview {
+ RecordingPauseButton(isPaused = true, onClick = {})
+ }
+}
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 cc75f0835f..355e3e827e 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
@@ -10,7 +10,9 @@ import android.view.KeyEvent
import android.view.Surface
import android.widget.Toast
import androidx.annotation.StringRes
+import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -45,30 +47,46 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
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.input.key.onPreviewKeyEvent
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import org.signal.camera.CameraDisplay
import org.signal.camera.CameraScreenState
import org.signal.camera.CaptureError
import org.signal.camera.FlashMode
import org.signal.camera.R
+import org.signal.camera.test.TestTags
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.rememberWindowBreakpoint
import java.util.Locale
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
/** Default maximum recording duration: 60 seconds */
const val DEFAULT_MAX_RECORDING_DURATION_MS = 60_000L
+/** Separates the zoom bar from the capture button below it on a phone. */
+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
+
data class StringResources(
@param:StringRes val photoCaptureFailed: Int = 0,
@param:StringRes val photoProcessingFailed: Int = 0,
@@ -110,7 +128,8 @@ data class StringResources(
* ```
*
* @param state The current camera screen state
- * @param maxRecordingDurationMs Maximum video recording duration in milliseconds (for progress indicator)
+ * @param maxRecordingDurationMs Maximum video recording duration in milliseconds, after which recording stops itself
+ * @param captureButtonMode Which kind of capture the button offers while it is not recording
* @param mediaSelectionCount Number of media items currently selected (shows count indicator when > 0)
* @param emitter Callback for HUD events (photo captured, video captured, gallery click)
*/
@@ -120,6 +139,7 @@ fun BoxScope.StandardCameraHud(
emitter: (StandardCameraHudEvents) -> Unit,
modifier: Modifier = Modifier,
maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS,
+ captureButtonMode: CaptureButtonMode = CaptureButtonMode.PHOTO,
hasAudioPermission: () -> Boolean = { true },
stringResources: StringResources = StringResources(0, 0)
) {
@@ -186,7 +206,7 @@ fun BoxScope.StandardCameraHud(
volumeKeyPressStartTime = 0
if (hasAudioPermission()) {
isRecordingFromVolumeKey = true
- emitter(StandardCameraHudEvents.VideoCaptureStarted)
+ emitter(StandardCameraHudEvents.VideoCaptureStarted(isLocked = false))
} else {
emitter(StandardCameraHudEvents.AudioPermissionRequired)
}
@@ -218,6 +238,7 @@ fun BoxScope.StandardCameraHud(
emitter = emitter,
modifier = modifier,
maxRecordingDurationMs = maxRecordingDurationMs,
+ captureButtonMode = captureButtonMode,
hasAudioPermission = hasAudioPermission,
stringResources = stringResources
)
@@ -230,6 +251,7 @@ private fun BoxScope.StandardCameraHudContent(
emitter: (StandardCameraHudEvents) -> Unit,
modifier: Modifier = Modifier,
maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS,
+ captureButtonMode: CaptureButtonMode = CaptureButtonMode.PHOTO,
hasAudioPermission: () -> Boolean = { true },
stringResources: StringResources = StringResources()
) {
@@ -239,14 +261,26 @@ private fun BoxScope.StandardCameraHudContent(
// The screen stays portrait on small; rotate the HUD icons to match the device so they stay upright.
val iconRotation = if (isPortraitPhone) uprightRotationDegrees(state.deviceRotation) else 0f
+ val captureButtonState = CaptureButtonState.of(
+ captureButtonMode = captureButtonMode,
+ isRecording = state.isRecording,
+ isRecordingLocked = state.isRecordingLocked
+ )
+
+ // A held recording leaves only the capture button and what the finger can reach from it.
+ val isRecordingHeld = captureButtonState == CaptureButtonState.RECORDING_HELD
+
ShutterOverlay(state.showShutter)
IconButton(
onClick = { emitter(StandardCameraHudEvents.CloseClick) },
+ enabled = !isRecordingHeld,
modifier = modifier
.padding(16.dp)
.size(48.dp)
+ .fadedIn(!isRecordingHeld)
.background(colorResource(R.color.CameraHud_control_background), shape = CircleShape)
+ .testTag(TestTags.CAMERA_HUD_CLOSE_BUTTON)
) {
Icon(
imageVector = SignalIcons.X.imageVector,
@@ -263,9 +297,11 @@ private fun BoxScope.StandardCameraHudContent(
flashMode = state.flashMode,
onToggle = { emitter(StandardCameraHudEvents.ToggleFlash) },
stringResources = stringResources,
+ enabled = !isRecordingHeld,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
+ .fadedIn(!isRecordingHeld)
.rotate(iconRotation)
)
}
@@ -279,19 +315,40 @@ private fun BoxScope.StandardCameraHudContent(
)
}
+ val cameraDisplay = CameraDisplay.rememberCameraDisplay(state.isLandscape)
+
+ // The bar takes its modifier from wherever it is put. On a display it has nothing to offer on it draws no node at
+ // all, so the spacing meant to separate it goes with it.
+ val zoomBar: @Composable (Modifier) -> Unit = { zoomBarModifier ->
+ ZoomBar(
+ zoomRatio = state.zoomRatio,
+ zoomRange = state.zoomRange,
+ cameraDisplay = cameraDisplay,
+ onZoomLevelClick = { emitter(StandardCameraHudEvents.SetZoomRatio(it.zoomLevel)) },
+ modifier = zoomBarModifier,
+ levelRotation = iconRotation,
+ visible = !isRecordingHeld
+ )
+ }
+
+ if (!isPortraitPhone) {
+ zoomBar(
+ Modifier
+ .align(Alignment.CenterStart)
+ .padding(start = ZOOM_BAR_SIDE_MARGIN)
+ )
+ }
+
CameraControls(
breakpoint = breakpoint,
iconRotation = iconRotation,
flashMode = state.flashMode,
- isRecording = state.isRecording,
- recordingProgress = if (maxRecordingDurationMs > 0) {
- (state.recordingDuration.toFloat() / maxRecordingDurationMs).coerceIn(0f, 1f)
- } else {
- 0f
- },
+ captureButtonState = captureButtonState,
+ isRecordingPaused = state.isRecordingPaused,
emitter = emitter,
hasAudioPermission = hasAudioPermission,
stringResources = stringResources,
+ zoomBarSlot = zoomBar,
modifier = modifier.align(if (isPortraitPhone) Alignment.BottomCenter else Alignment.CenterEnd)
)
}
@@ -312,6 +369,30 @@ private fun ShutterOverlay(showFlash: Boolean) {
}
}
+/** Rotates an offset by [degrees], to carry it between two frames rotated against each other. */
+private fun Offset.rotatedBy(degrees: Float): Offset {
+ if (degrees == 0f) {
+ return this
+ }
+
+ val radians = degrees * PI.toFloat() / 180f
+ val cosine = cos(radians)
+ val sine = sin(radians)
+
+ return Offset(x = x * cosine - y * sine, y = x * sine + y * cosine)
+}
+
+/**
+ * Fades a piece of chrome in or out in place. It keeps its space in the layout while it is gone, so what is around it
+ * cannot move out from under a finger that is midway through a gesture.
+ */
+@Composable
+private fun Modifier.fadedIn(visible: Boolean): Modifier {
+ val chromeAlpha by animateFloatAsState(targetValue = if (visible) 1f else 0f, label = "HudChromeAlpha")
+
+ return graphicsLayer { alpha = chromeAlpha }
+}
+
/** Degrees to rotate a HUD icon so it stays upright at the given committed [Surface] rotation. */
private fun uprightRotationDegrees(surfaceRotation: Int): Float = when (surfaceRotation) {
Surface.ROTATION_90 -> 90f
@@ -320,6 +401,51 @@ private fun uprightRotationDegrees(surfaceRotation: Int): Float = when (surfaceR
else -> 0f
}
+/** What the HUD has asked the camera for, which it may not have reported yet. */
+private enum class RequestedRecording {
+ /** Nothing outstanding, so the button is free to ask for a recording. */
+ NONE,
+
+ /** A recording that runs on its own, so lifting a finger does not end it. */
+ UNHELD,
+
+ /** A recording the finger still on the capture button is keeping open, so lifting it ends it. */
+ HELD
+}
+
+/**
+ * What stands in the gallery's corner. Everything it can hold is the same circle, so each fades into the next in place.
+ *
+ * @param onLockCenterChanged Where the lock sits in the root's frame, which is one end of the drag that takes it.
+ */
+@Composable
+private fun GallerySlot(
+ captureButtonState: CaptureButtonState,
+ isRecordingPaused: Boolean,
+ emitter: (StandardCameraHudEvents) -> Unit,
+ onLockCenterChanged: (Offset) -> Unit
+) {
+ AnimatedContent(
+ targetState = GallerySlotContent.of(captureButtonState),
+ transitionSpec = { CameraHudMotion.swap },
+ label = "GallerySlotContent",
+ modifier = Modifier.onGloballyPositioned { onLockCenterChanged(it.boundsInRoot().center) }
+ ) { slotContent ->
+ when (slotContent) {
+ GallerySlotContent.GALLERY -> GalleryThumbnailButton(
+ onClick = { emitter(StandardCameraHudEvents.GalleryClick) },
+ enabled = !captureButtonState.isRecording
+ )
+
+ GallerySlotContent.LOCK -> RecordingLockButton()
+ GallerySlotContent.PAUSE -> RecordingPauseButton(
+ isPaused = isRecordingPaused,
+ onClick = { emitter(StandardCameraHudEvents.RecordingPauseToggled) }
+ )
+ }
+ }
+}
+
/**
* Camera control buttons layout with center element always truly centered
* and side elements at fixed distances from edges.
@@ -328,41 +454,102 @@ private fun uprightRotationDegrees(surfaceRotation: Int): Float = when (surfaceR
private fun CameraControls(
breakpoint: WindowBreakpoint,
iconRotation: Float,
- isRecording: Boolean,
- recordingProgress: Float,
+ captureButtonState: CaptureButtonState,
+ isRecordingPaused: Boolean,
flashMode: FlashMode,
emitter: (StandardCameraHudEvents) -> Unit,
hasAudioPermission: () -> Boolean,
stringResources: StringResources,
+ zoomBarSlot: @Composable (Modifier) -> Unit,
modifier: Modifier = Modifier
) {
val orientation = LocalConfiguration.current.orientation
val currentEmitter by rememberUpdatedState(emitter)
val currentHasAudioPermission by rememberUpdatedState(hasAudioPermission)
+ val currentIsRecordingPaused by rememberUpdatedState(isRecordingPaused)
- val gallery: @Composable () -> Unit = remember {
- movableContentOf {
- GalleryThumbnailButton(onClick = { currentEmitter(StandardCameraHudEvents.GalleryClick) })
+ // A recording takes a moment to report itself as running, so this is what the button goes by in the meantime.
+ var requestedRecording by remember { mutableStateOf(RequestedRecording.NONE) }
+
+ LaunchedEffect(captureButtonState.isRecording) {
+ if (!captureButtonState.isRecording) {
+ requestedRecording = RequestedRecording.NONE
}
}
- // isRecording/recordingProgress are passed as movable-content parameters so they are read fresh on
- // every invocation; capturing them in the remembered lambda would freeze them at first composition.
- val captureButton: @Composable (Boolean, Float) -> Unit = remember {
- movableContentOf { isRecording, recordingProgress ->
- CaptureButton(
- isRecording = isRecording,
- recordingProgress = recordingProgress,
- onTap = { currentEmitter(StandardCameraHudEvents.PhotoCaptureTriggered) },
- onLongPressStart = {
- if (currentHasAudioPermission()) {
- currentEmitter(StandardCameraHudEvents.VideoCaptureStarted)
- } else {
+ // Where the two ends of the drag to the lock are. They are measured rather than derived, since the lock sits beside
+ // the capture button on a phone and below it on anything larger.
+ var captureButtonCenter by remember { mutableStateOf(Offset.Zero) }
+ var lockCenter by remember { mutableStateOf(Offset.Zero) }
+
+ val gallery: @Composable (CaptureButtonState) -> Unit = remember {
+ movableContentOf { captureButtonState ->
+ GallerySlot(
+ captureButtonState = captureButtonState,
+ isRecordingPaused = currentIsRecordingPaused,
+ emitter = currentEmitter,
+ onLockCenterChanged = { lockCenter = it }
+ )
+ }
+ }
+
+ // The state is passed as a movable-content parameter so it is read fresh on every invocation; capturing it in the
+ // remembered lambda would freeze it at first composition.
+ val captureButton: @Composable (CaptureButtonState) -> Unit = remember {
+ movableContentOf { captureButtonState ->
+ // Emits an event and reports whether it went out. A recording is turned away while the microphone is unavailable,
+ // or while one already asked for has yet to be reported: the camera would refuse it, and the gesture behind it
+ // would still believe it owned what the first ask started.
+ val request: (StandardCameraHudEvents) -> Boolean = { event ->
+ when {
+ event is StandardCameraHudEvents.VideoCaptureStarted && !currentHasAudioPermission() -> {
currentEmitter(StandardCameraHudEvents.AudioPermissionRequired)
+ false
+ }
+
+ event is StandardCameraHudEvents.VideoCaptureStarted && requestedRecording != RequestedRecording.NONE -> false
+
+ else -> {
+ requestedRecording = when (event) {
+ is StandardCameraHudEvents.VideoCaptureStarted -> if (event.isLocked) RequestedRecording.UNHELD else RequestedRecording.HELD
+ is StandardCameraHudEvents.VideoCaptureStopped -> RequestedRecording.NONE
+ else -> requestedRecording
+ }
+
+ currentEmitter(event)
+ true
+ }
+ }
+ }
+
+ // A drag is measured in the button's own frame, and on a phone that frame turns with the device, so the way to
+ // the lock has to be turned with it.
+ val lockOffset = if (captureButtonState == CaptureButtonState.RECORDING_HELD && lockCenter != Offset.Zero) {
+ (lockCenter - captureButtonCenter).rotatedBy(-iconRotation)
+ } else {
+ Offset.Zero
+ }
+
+ CaptureButton(
+ state = captureButtonState,
+ modifier = Modifier.onGloballyPositioned { captureButtonCenter = it.boundsInRoot().center },
+ lockOffset = lockOffset,
+ onLock = {
+ requestedRecording = RequestedRecording.UNHELD
+ currentEmitter(StandardCameraHudEvents.VideoCaptureLocked)
+ },
+ onTap = { captureButtonState.tapRequest?.let { request(it) } },
+ onLongPressStart = {
+ if (!captureButtonState.isRecording) {
+ request(StandardCameraHudEvents.VideoCaptureStarted(isLocked = false))
+ }
+ },
+ onLongPressEnd = {
+ if (requestedRecording == RequestedRecording.HELD || captureButtonState == CaptureButtonState.RECORDING_HELD) {
+ request(StandardCameraHudEvents.VideoCaptureStopped)
}
},
- onLongPressEnd = { currentEmitter(StandardCameraHudEvents.VideoCaptureStopped) },
onZoomChange = { currentEmitter(StandardCameraHudEvents.SetZoomLevel(it)) }
)
}
@@ -373,11 +560,11 @@ private fun CameraControls(
HorizontalControlBar(
gallerySlot = gallery,
captureSlot = captureButton,
- isRecording = isRecording,
- recordingProgress = recordingProgress,
+ captureButtonState = captureButtonState,
iconRotation = iconRotation,
stringResources = stringResources,
emitter = emitter,
+ zoomBarSlot = zoomBarSlot,
modifier = modifier
)
}
@@ -387,8 +574,7 @@ private fun CameraControls(
flashMode = flashMode,
gallerySlot = gallery,
captureSlot = captureButton,
- isRecording = isRecording,
- recordingProgress = recordingProgress,
+ captureButtonState = captureButtonState,
stringResources = stringResources,
emitter = emitter,
modifier = modifier
@@ -399,31 +585,40 @@ private fun CameraControls(
@Composable
private fun HorizontalControlBar(
- gallerySlot: @Composable () -> Unit,
- captureSlot: @Composable (Boolean, Float) -> Unit,
- isRecording: Boolean,
- recordingProgress: Float,
+ gallerySlot: @Composable (CaptureButtonState) -> Unit,
+ captureSlot: @Composable (CaptureButtonState) -> Unit,
+ captureButtonState: CaptureButtonState,
iconRotation: Float,
stringResources: StringResources,
emitter: (StandardCameraHudEvents) -> Unit,
+ zoomBarSlot: @Composable (Modifier) -> Unit,
modifier: Modifier
) {
- Box(
- modifier = modifier
- .fillMaxWidth()
- .padding(bottom = 40.dp, start = 40.dp, end = 40.dp)
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = modifier.fillMaxWidth()
) {
- Box(modifier = Modifier.align(Alignment.CenterEnd).rotate(iconRotation)) {
- CameraSwitchButton(
- onClick = { emitter(StandardCameraHudEvents.SwitchCamera) },
- stringResources = stringResources
- )
- }
- Box(modifier = Modifier.align(Alignment.Center).rotate(iconRotation)) {
- captureSlot(isRecording, recordingProgress)
- }
- Box(modifier = Modifier.align(Alignment.CenterStart).rotate(iconRotation)) {
- gallerySlot()
+ zoomBarSlot(Modifier.padding(bottom = ZOOM_BAR_BOTTOM_MARGIN))
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 40.dp, start = 40.dp, end = 40.dp)
+ ) {
+ Box(modifier = Modifier.align(Alignment.CenterEnd).rotate(iconRotation)) {
+ CameraSwitchButton(
+ onClick = { emitter(StandardCameraHudEvents.SwitchCamera) },
+ stringResources = stringResources,
+ enabled = captureButtonState != CaptureButtonState.RECORDING_HELD,
+ modifier = Modifier.fadedIn(captureButtonState != CaptureButtonState.RECORDING_HELD)
+ )
+ }
+ Box(modifier = Modifier.align(Alignment.Center).rotate(iconRotation)) {
+ captureSlot(captureButtonState)
+ }
+ Box(modifier = Modifier.align(Alignment.CenterStart).rotate(iconRotation)) {
+ gallerySlot(captureButtonState)
+ }
}
}
}
@@ -431,10 +626,9 @@ private fun HorizontalControlBar(
@Composable
private fun VerticalControlBar(
flashMode: FlashMode,
- gallerySlot: @Composable () -> Unit,
- captureSlot: @Composable (Boolean, Float) -> Unit,
- isRecording: Boolean,
- recordingProgress: Float,
+ gallerySlot: @Composable (CaptureButtonState) -> Unit,
+ captureSlot: @Composable (CaptureButtonState) -> Unit,
+ captureButtonState: CaptureButtonState,
stringResources: StringResources,
emitter: (StandardCameraHudEvents) -> Unit,
modifier: Modifier
@@ -455,11 +649,13 @@ private fun VerticalControlBar(
FlashAndCameraTogglePill(
flashMode = flashMode,
emitter = emitter,
- stringResources = stringResources
+ stringResources = stringResources,
+ enabled = captureButtonState != CaptureButtonState.RECORDING_HELD,
+ modifier = Modifier.fadedIn(captureButtonState != CaptureButtonState.RECORDING_HELD)
)
}
- captureSlot(isRecording, recordingProgress)
+ captureSlot(captureButtonState)
Box(
contentAlignment = Alignment.TopCenter,
@@ -467,7 +663,7 @@ private fun VerticalControlBar(
.weight(1f)
.padding(top = 40.dp)
) {
- gallerySlot()
+ gallerySlot(captureButtonState)
}
}
}
@@ -476,16 +672,20 @@ private fun VerticalControlBar(
private fun FlashAndCameraTogglePill(
flashMode: FlashMode,
stringResources: StringResources,
- emitter: (StandardCameraHudEvents) -> Unit
+ emitter: (StandardCameraHudEvents) -> Unit,
+ enabled: Boolean = true,
+ modifier: Modifier = Modifier
) {
Column(
- modifier = Modifier.background(
+ modifier = modifier.background(
color = colorResource(R.color.CameraHud_control_background),
shape = RoundedCornerShape(50)
)
) {
IconButton(
- onClick = { emitter(StandardCameraHudEvents.ToggleFlash) }
+ onClick = { emitter(StandardCameraHudEvents.ToggleFlash) },
+ enabled = enabled,
+ modifier = Modifier.testTag(TestTags.CAMERA_HUD_FLASH_BUTTON)
) {
FlashToggleButtonIcon(
flashMode = flashMode,
@@ -494,7 +694,9 @@ private fun FlashAndCameraTogglePill(
}
IconButton(
- onClick = { emitter(StandardCameraHudEvents.SwitchCamera) }
+ onClick = { emitter(StandardCameraHudEvents.SwitchCamera) },
+ enabled = enabled,
+ modifier = Modifier.testTag(TestTags.CAMERA_HUD_SWITCH_BUTTON)
) {
Icon(
imageVector = SignalIcons.CameraSwitch.imageVector,
@@ -518,6 +720,7 @@ private fun RecordingDurationDisplay(
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,
@@ -532,6 +735,7 @@ private fun RecordingDurationDisplay(
private fun CameraSwitchButton(
onClick: () -> Unit,
stringResources: StringResources,
+ enabled: Boolean = true,
modifier: Modifier = Modifier
) {
val contentDescription = if (stringResources.switchCamera != 0) {
@@ -542,9 +746,11 @@ private fun CameraSwitchButton(
IconButton(
onClick = onClick,
+ enabled = enabled,
modifier = modifier
.size(52.dp)
.background(colorResource(R.color.CameraHud_control_background), shape = CircleShape)
+ .testTag(TestTags.CAMERA_HUD_SWITCH_BUTTON)
) {
Icon(
imageVector = SignalIcons.CameraSwitch.imageVector,
@@ -560,13 +766,16 @@ private fun FlashToggleButton(
flashMode: FlashMode,
onToggle: () -> Unit,
stringResources: StringResources,
+ enabled: Boolean = true,
modifier: Modifier = Modifier
) {
IconButton(
onClick = onToggle,
+ enabled = enabled,
modifier = modifier
.size(48.dp)
.background(colorResource(R.color.CameraHud_control_background), shape = CircleShape)
+ .testTag(TestTags.CAMERA_HUD_FLASH_BUTTON)
) {
FlashToggleButtonIcon(
flashMode = flashMode,
@@ -617,6 +826,21 @@ private fun StandardCameraHudPreview() {
}
}
+/**
+ * The zoom bar above the capture button. The canvas is taller than 16:9 on purpose — that is the one window the bar has
+ * no room on, so a 640dp-tall preview would show nothing.
+ */
+@Preview(name = "Zoom bar", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 760)
+@Composable
+private fun StandardCameraHudZoomBarPreview() {
+ Box(modifier = Modifier.fillMaxSize()) {
+ StandardCameraHudContent(
+ state = CameraScreenState(zoomRange = 0.5f..10f),
+ emitter = {}
+ )
+ }
+}
+
@Preview(name = "Recording", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 640)
@Composable
private fun StandardCameraHudRecordingPreview() {
@@ -633,6 +857,55 @@ private fun StandardCameraHudRecordingPreview() {
}
}
+/**
+ * A recording being held: everything but the capture button and the lock that has taken the gallery's place is faded
+ * out, since the finger holding the recording open cannot reach any of it.
+ */
+@Preview(name = "Recording held", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 760)
+@Composable
+private fun StandardCameraHudHeldRecordingPreview() {
+ Box(modifier = Modifier.fillMaxSize()) {
+ StandardCameraHudContent(
+ state = CameraScreenState(
+ isRecording = true,
+ recordingDuration = 4_000L,
+ zoomRange = 0.5f..10f
+ ),
+ maxRecordingDurationMs = 30_000L,
+ emitter = {}
+ )
+ }
+}
+
+@Preview(name = "Recording locked", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 640)
+@Composable
+private fun StandardCameraHudLockedRecordingPreview() {
+ Box(modifier = Modifier.fillMaxSize()) {
+ StandardCameraHudContent(
+ state = CameraScreenState(
+ isRecording = true,
+ isRecordingLocked = true,
+ recordingDuration = 18_000L
+ ),
+ maxRecordingDurationMs = 30_000L,
+ captureButtonMode = CaptureButtonMode.VIDEO,
+ emitter = {}
+ )
+ }
+}
+
+@Preview(name = "Video mode", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 640)
+@Composable
+private fun StandardCameraHudVideoModePreview() {
+ Box(modifier = Modifier.fillMaxSize()) {
+ StandardCameraHudContent(
+ state = CameraScreenState(),
+ captureButtonMode = CaptureButtonMode.VIDEO,
+ emitter = {}
+ )
+ }
+}
+
@Preview(name = "With Close Button", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 640)
@Composable
private fun StandardCameraHudWithMediaPreview() {
diff --git a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHudEvents.kt b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHudEvents.kt
index 8781e8066c..60c6dff3a6 100644
--- a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHudEvents.kt
+++ b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHudEvents.kt
@@ -10,14 +10,27 @@ sealed interface StandardCameraHudEvents {
data object PhotoCaptureTriggered : StandardCameraHudEvents
- data object VideoCaptureStarted : StandardCameraHudEvents
+ /**
+ * @param isLocked Whether the recording runs until it is stopped rather than for only as long as the capture button
+ * is held.
+ */
+ data class VideoCaptureStarted(val isLocked: Boolean) : StandardCameraHudEvents
data object VideoCaptureStopped : StandardCameraHudEvents
+ /** A drag reached the lock, so the recording that was being held should carry on without the finger. */
+ data object VideoCaptureLocked : StandardCameraHudEvents
+
+ /** The running recording was asked to pause, or a paused one to resume. */
+ data object RecordingPauseToggled : StandardCameraHudEvents
+
data object SwitchCamera : StandardCameraHudEvents
data class SetZoomLevel(@param:FloatRange(from = -1.0, to = 1.0) val zoomLevel: Float) : StandardCameraHudEvents
+ /** A level was picked off the zoom bar: a ratio to go straight to rather than a drag away from the current one. */
+ data class SetZoomRatio(val zoomRatio: Float) : StandardCameraHudEvents
+
/**
* Emitted when the gallery button is clicked.
*/
diff --git a/feature/camera/src/main/java/org/signal/camera/hud/ZoomBar.kt b/feature/camera/src/main/java/org/signal/camera/hud/ZoomBar.kt
new file mode 100644
index 0000000000..f809d06471
--- /dev/null
+++ b/feature/camera/src/main/java/org/signal/camera/hud/ZoomBar.kt
@@ -0,0 +1,260 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.signal.camera.CameraDisplay
+import org.signal.camera.R
+import org.signal.camera.test.TestTags
+import org.signal.core.ui.compose.FoldableNightPreviews
+import org.signal.core.ui.compose.PhoneNightPreviews
+import org.signal.core.ui.compose.Previews
+
+private object ZoomBarColors {
+ /** The selected level, on the bar's own background. */
+ val SelectedLevelHorizontal = Color(0x33FFFFFF)
+
+ /** The selected level, on the viewfinder itself, where there is no bar behind it. */
+ val SelectedLevelVertical = Color(0xCC333333)
+}
+
+private object ZoomBarDimensions {
+ /** Each level's tap target, which is also how thick the bar is. */
+ val LevelSize = 40.dp
+}
+
+private val ZoomBarShape = RoundedCornerShape(percent = 50)
+
+/**
+ * Which way the levels run, following the controls the bar sits with: a portrait phone keeps its controls along the
+ * bottom and the bar above the capture button; everything else runs them down the side.
+ *
+ * Keyed on the window rather than the breakpoint alone, since a phone turned landscape lays out the way a larger device
+ * does.
+ */
+private val CameraDisplay.stacksLevels: Boolean
+ get() = when (this) {
+ CameraDisplay.LARGE_PORTRAIT, CameraDisplay.LARGE_LANDSCAPE -> true
+ else -> false
+ }
+
+/**
+ * Bar which displays predetermined zoom levels.
+ *
+ * It offers the levels the lens can reach and the viewfinder leaves room for, and shows as selected wherever the camera
+ * actually is, so a zoom arrived at some other way — a pinch, or a drag along the capture button — is reflected here. A
+ * ratio between two levels selects neither.
+ *
+ * @param zoomRatio Where the camera is now, as it reports it
+ * @param zoomRange What the bound lens can reach
+ * @param cameraDisplay The window the bar has to fit in, which decides how many levels it can offer at all
+ * @param onZoomLevelClick A level was picked, which the camera is expected to jump straight to
+ * @param levelRotation Degrees to rotate each level by so it stays upright as the device turns. The bar itself holds
+ * still; only the numbers read wrong when the device is rotated.
+ * @param visible Whether the bar can be used. It fades rather than leaving, and keeps its place while it is gone, so
+ * whatever sits next to it cannot move out from under the finger that put it away.
+ */
+@Composable
+fun ZoomBar(
+ zoomRatio: Float,
+ zoomRange: ClosedFloatingPointRange,
+ cameraDisplay: CameraDisplay,
+ onZoomLevelClick: (ZoomBarLevel) -> Unit,
+ modifier: Modifier = Modifier,
+ levelRotation: Float = 0f,
+ visible: Boolean = true
+) {
+ val availableLevels = remember(zoomRange, cameraDisplay) { ZoomBarLevel.availableIn(zoomRange, cameraDisplay) }
+
+ // A single level is nothing to switch between, so no bar goes up at all.
+ if (availableLevels.size < 2) {
+ return
+ }
+
+ val selectedLevel = ZoomBarLevel.of(zoomRatio, availableLevels)
+ val barAlpha by animateFloatAsState(targetValue = if (visible) 1f else 0f, label = "ZoomBarAlpha")
+
+ val barModifier = modifier
+ .graphicsLayer { alpha = barAlpha }
+ .selectableGroup()
+ .testTag(TestTags.CAMERA_HUD_ZOOM_BAR)
+
+ if (cameraDisplay.stacksLevels) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = barModifier
+ ) {
+ ZoomLevels(
+ availableLevels = availableLevels,
+ selectedLevel = selectedLevel,
+ selectedLevelColor = ZoomBarColors.SelectedLevelVertical,
+ levelRotation = levelRotation,
+ enabled = visible,
+ onZoomLevelClick = onZoomLevelClick
+ )
+ }
+ } else {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = barModifier
+ .background(color = colorResource(R.color.CameraHud_control_background), shape = ZoomBarShape)
+ ) {
+ ZoomLevels(
+ availableLevels = availableLevels,
+ selectedLevel = selectedLevel,
+ selectedLevelColor = ZoomBarColors.SelectedLevelHorizontal,
+ levelRotation = levelRotation,
+ enabled = visible,
+ onZoomLevelClick = onZoomLevelClick
+ )
+ }
+ }
+}
+
+@Composable
+private fun ZoomLevels(
+ availableLevels: List,
+ selectedLevel: ZoomBarLevel?,
+ selectedLevelColor: Color,
+ levelRotation: Float,
+ enabled: Boolean,
+ onZoomLevelClick: (ZoomBarLevel) -> Unit
+) {
+ for (level in availableLevels) {
+ val isSelected = level == selectedLevel
+ val background by animateColorAsState(
+ targetValue = if (isSelected) selectedLevelColor else Color.Transparent,
+ label = "ZoomBarSelectedLevel"
+ )
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .defaultMinSize(minWidth = ZoomBarDimensions.LevelSize, minHeight = ZoomBarDimensions.LevelSize)
+ .clip(CircleShape)
+ .background(color = background, shape = CircleShape)
+ .selectable(
+ selected = isSelected,
+ enabled = enabled,
+ onClick = { onZoomLevelClick(level) }
+ )
+ ) {
+ // Only the text rotates: the level keeps its place on the bar and its tap target.
+ Text(
+ text = level.label,
+ color = Color.White,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.rotate(levelRotation)
+ )
+ }
+ }
+}
+
+@PhoneNightPreviews
+@Composable
+private fun ZoomBarPreviews() {
+ ZoomBarPreview()
+}
+
+/** Anything larger than a portrait phone runs the levels down the start side instead. */
+@FoldableNightPreviews
+@Composable
+private fun ZoomBarStackedPreviews() {
+ ZoomBarPreview(cameraDisplay = CameraDisplay.LARGE_PORTRAIT)
+}
+
+/** A lens that reaches neither the ultra-wide nor the long end offers only the levels in between. */
+@Preview(name = "Limited lens", showBackground = true, backgroundColor = 0xFF444444)
+@Composable
+private fun ZoomBarLimitedLensPreview() {
+ ZoomBarPreview(zoomRange = 1f..3f)
+}
+
+/** The tallest window has room for every level the lens reaches. */
+@Preview(name = "Roomy window", showBackground = true, backgroundColor = 0xFF444444)
+@Composable
+private fun ZoomBarRoomyWindowPreview() {
+ ZoomBarPreview(cameraDisplay = CameraDisplay.DISPLAY_20_9)
+}
+
+/** The next window up from the shortest has room for two levels, whatever else the lens can reach. */
+@Preview(name = "Room for two", showBackground = true, backgroundColor = 0xFF444444)
+@Composable
+private fun ZoomBarRoomForTwoPreview() {
+ ZoomBarPreview(cameraDisplay = CameraDisplay.DISPLAY_18_9)
+}
+
+/** The shortest window has no room for the bar, so this draws nothing. */
+@Preview(name = "No room", showBackground = true, backgroundColor = 0xFF444444)
+@Composable
+private fun ZoomBarNoRoomPreview() {
+ ZoomBarPreview(cameraDisplay = CameraDisplay.DISPLAY_16_9)
+}
+
+/** Where a pinch tends to leave the camera: at no level in particular. */
+@Preview(name = "Between levels", showBackground = true, backgroundColor = 0xFF444444)
+@Composable
+private fun ZoomBarBetweenLevelsPreview() {
+ ZoomBarPreview(initialZoomRatio = 3.4f)
+}
+
+/** A phone turned on its side: the bar holds its place and only the numbers rotate. */
+@Preview(name = "Device turned", showBackground = true, backgroundColor = 0xFF444444)
+@Composable
+private fun ZoomBarRotatedPreview() {
+ ZoomBarPreview(levelRotation = 90f)
+}
+
+@Composable
+private fun ZoomBarPreview(
+ zoomRange: ClosedFloatingPointRange = 0.5f..10f,
+ cameraDisplay: CameraDisplay = CameraDisplay.DISPLAY_20_9,
+ initialZoomRatio: Float = 1f,
+ levelRotation: Float = 0f
+) {
+ var zoomRatio by remember { mutableFloatStateOf(initialZoomRatio) }
+
+ Previews.Preview {
+ ZoomBar(
+ zoomRatio = zoomRatio,
+ zoomRange = zoomRange,
+ cameraDisplay = cameraDisplay,
+ onZoomLevelClick = { zoomRatio = it.zoomLevel },
+ levelRotation = levelRotation
+ )
+ }
+}
diff --git a/feature/camera/src/main/java/org/signal/camera/hud/ZoomBarLevel.kt b/feature/camera/src/main/java/org/signal/camera/hud/ZoomBarLevel.kt
new file mode 100644
index 0000000000..8dc949fe03
--- /dev/null
+++ b/feature/camera/src/main/java/org/signal/camera/hud/ZoomBarLevel.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import org.signal.camera.CameraDisplay
+import kotlin.math.abs
+
+/**
+ * A zoom the bar offers as a single tap: a ratio the camera is asked to go straight to rather than one the user has to
+ * drag their way to.
+ *
+ * @param label What the level reads as on the bar, which is the ratio rather than the name.
+ */
+enum class ZoomBarLevel(val zoomLevel: Float, val label: String) {
+ HALF(0.5f, ".5"),
+ ONE(1f, "1"),
+ TWO(2f, "2"),
+ FIVE(5f, "5");
+
+ companion object {
+
+ /**
+ * How near a ratio has to be to count as being at a level, as a fraction of the level so it means the same thing at
+ * every one of them. A camera lands where its hardware allows rather than exactly where it was sent — a lens that
+ * fuses an ultra-wide in reaches something like 0.506 rather than a round half — and a pinch passes through a level
+ * rather than settling on it.
+ *
+ * Both offering a level and calling it selected are measured by it, so the bar cannot withhold a level it would have
+ * counted as reached, or offer one it would not.
+ */
+ private const val MATCH_TOLERANCE_FRACTION = 0.02f
+
+ /**
+ * The levels worth offering: the ones the viewfinder leaves room for and the lens can get near enough to. A level
+ * the lens would be clamped away from is a tap that goes somewhere else.
+ */
+ fun availableIn(zoomRange: ClosedFloatingPointRange, cameraDisplay: CameraDisplay): List = offeredBy(cameraDisplay).filter { it.isReachableIn(zoomRange) }
+
+ /**
+ * How many levels the window has room for once the viewfinder has taken its share. The bar sits above the capture
+ * button on a phone and along the start side on anything larger, so the shortest window — filled by the viewfinder
+ * edge to edge — has nowhere to put it, and the next one up has room for only the two levels nearest 1x.
+ */
+ private fun offeredBy(cameraDisplay: CameraDisplay): List = when (cameraDisplay) {
+ CameraDisplay.DISPLAY_16_9 -> emptyList()
+ CameraDisplay.DISPLAY_18_9 -> listOf(ONE, TWO)
+ else -> entries
+ }
+
+ /**
+ * Which of [availableLevels] the camera is sitting at, or null for a ratio between two of them, which is where a
+ * pinch or a drag along the capture button tends to leave it. Only ever a level that is on the bar, so a ratio
+ * reached some other way cannot light up a level the user cannot see.
+ */
+ fun of(zoomRatio: Float, availableLevels: List): ZoomBarLevel? = availableLevels.firstOrNull { it.isAt(zoomRatio) }
+
+ /**
+ * Whether asking for this level would land near enough to it to count. The camera clamps what it is sent into what
+ * it can reach, so the clamped ratio is what the level is measured against.
+ */
+ private fun ZoomBarLevel.isReachableIn(zoomRange: ClosedFloatingPointRange): Boolean = isAt(zoomLevel.coerceIn(zoomRange))
+
+ /** Whether [zoomRatio] is near enough this level to read as being at it. */
+ private fun ZoomBarLevel.isAt(zoomRatio: Float): Boolean = abs(zoomRatio - zoomLevel) <= zoomLevel * MATCH_TOLERANCE_FRACTION
+ }
+}
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
new file mode 100644
index 0000000000..a7f05328da
--- /dev/null
+++ b/feature/camera/src/main/java/org/signal/camera/test/TestTags.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.test
+
+/**
+ * Tags for finding the camera's controls from a test. Each is applied by the control it names rather than by whichever
+ * screen composed it.
+ *
+ * A tag names one control, not one place it is put. The flash and the camera switch each stand alone on a portrait phone
+ * and sit together in a pill on anything larger; the two layouts are gated on the same condition, so whichever is up
+ * there is exactly one of each to find.
+ */
+object TestTags {
+
+ // Camera HUD. The close button is the one tag the HUD applies itself, having no component of its own to hang it on.
+ const val CAMERA_HUD_CLOSE_BUTTON = "camera_hud_close_button"
+ const val CAMERA_HUD_FLASH_BUTTON = "camera_hud_flash_button"
+ 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_ZOOM_BAR = "camera_hud_zoom_bar"
+
+ // What the gallery's corner holds, one at a time
+ const val CAMERA_HUD_GALLERY_BUTTON = "camera_hud_gallery_button"
+ const val CAMERA_HUD_LOCK_BUTTON = "camera_hud_lock_button"
+ const val CAMERA_HUD_PAUSE_BUTTON = "camera_hud_pause_button"
+}
diff --git a/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt b/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt
index 3636e5da47..05974ed63d 100644
--- a/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt
+++ b/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt
@@ -18,8 +18,9 @@ import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.VideoCapture
import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.LifecycleOwner
-import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
import assertk.assertThat
+import assertk.assertions.isCloseTo
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
@@ -58,7 +59,9 @@ class CameraScreenViewModelTest {
private val mockContext: Context = mockk(relaxed = true)
private val mockCameraControl: CameraControl = mockk(relaxed = true)
private val mockCameraInfo: CameraInfo = mockk(relaxed = true)
- private val mockZoomStateLiveData: LiveData = mockk(relaxed = true)
+
+ /** A real LiveData rather than a mock, so the view model's observer runs the way it does on a device. */
+ private val zoomStateLiveData = MutableLiveData()
private val mockCamera: Camera = mockk(relaxed = true)
private lateinit var viewModel: CameraScreenViewModel
@@ -76,8 +79,7 @@ class CameraScreenViewModelTest {
every { mockCamera.cameraControl } returns mockCameraControl
every { mockCamera.cameraInfo } returns mockCameraInfo
- every { mockCameraInfo.zoomState } returns mockZoomStateLiveData
- every { mockZoomStateLiveData.value } returns null
+ every { mockCameraInfo.zoomState } returns zoomStateLiveData
every { mockCameraProvider.bindToLifecycle(any(), any(), *anyVararg()) } returns mockCamera
every { mockCameraProvider.unbindAll() } just Runs
@@ -138,11 +140,12 @@ class CameraScreenViewModelTest {
private fun List.hasImageAnalysis() = any { it is ImageAnalysis }
private fun List.imageCapture() = filterIsInstance().firstOrNull()
- private fun setupZoomState(minZoom: Float, maxZoom: Float) {
- val mockZoomState: ZoomState = mockk()
- every { mockZoomState.minZoomRatio } returns minZoom
- every { mockZoomState.maxZoomRatio } returns maxZoom
- every { mockZoomStateLiveData.value } returns mockZoomState
+ private fun setupZoomState(minZoom: Float, maxZoom: Float, zoomRatio: Float = 1f) {
+ zoomStateLiveData.value = mockk().also {
+ every { it.minZoomRatio } returns minZoom
+ every { it.maxZoomRatio } returns maxZoom
+ every { it.zoomRatio } returns zoomRatio
+ }
}
// ===========================================================================
@@ -255,7 +258,7 @@ class CameraScreenViewModelTest {
val postInitAttempts = captureBindingAttempts()
try {
- viewModel.startRecording(mockContext, VideoOutput.FileOutput(File.createTempFile("video", ".mp4")), {})
+ viewModel.startRecording(mockContext, VideoOutput.FileOutput(File.createTempFile("video", ".mp4")), onVideoCaptured = {})
} catch (_: Exception) {
// Recording internals may not work fully in the test environment
}
@@ -272,7 +275,7 @@ class CameraScreenViewModelTest {
val postInitAttempts = captureBindingAttempts()
try {
- viewModel.startRecording(mockContext, VideoOutput.FileOutput(File.createTempFile("video", ".mp4")), {})
+ viewModel.startRecording(mockContext, VideoOutput.FileOutput(File.createTempFile("video", ".mp4")), onVideoCaptured = {})
} catch (_: Exception) {
// Recording internals may not work fully in the test environment
}
@@ -289,7 +292,7 @@ class CameraScreenViewModelTest {
val postInitAttempts = captureBindingAttempts(failCount = Int.MAX_VALUE)
try {
- viewModel.startRecording(mockContext, VideoOutput.FileOutput(File.createTempFile("video", ".mp4")), {})
+ viewModel.startRecording(mockContext, VideoOutput.FileOutput(File.createTempFile("video", ".mp4")), onVideoCaptured = {})
} catch (_: Exception) {
// Expected — video rebind threw, which triggers the restore path
}
@@ -493,6 +496,81 @@ class CameraScreenViewModelTest {
verify { mockCameraControl.startFocusAndMetering(any()) }
}
+ // ===========================================================================
+ // Zoom bar animation
+ // ===========================================================================
+
+ @Test
+ fun `SetZoomRatio does not arrive at the level the moment it is asked for`() {
+ setupZoomState(minZoom = 1f, maxZoom = 16f)
+ bindCamera()
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(4f))
+
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(1f)
+ }
+
+ @Test
+ fun `SetZoomRatio is on its way to the level partway through`() {
+ setupZoomState(minZoom = 1f, maxZoom = 16f)
+ bindCamera()
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(4f))
+ testDispatcher.scheduler.advanceTimeBy(HALFWAY_MS)
+
+ val zoomRatio = viewModel.state.value.zoomRatio
+ assertThat(zoomRatio).isGreaterThan(1f)
+ assertThat(zoomRatio < 4f).isTrue()
+ }
+
+ @Test
+ fun `SetZoomRatio finishes on the level exactly`() {
+ setupZoomState(minZoom = 1f, maxZoom = 16f)
+ bindCamera()
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(4f))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(4f)
+ }
+
+ /**
+ * Two animations the same distance apart in ratio terms cover the same fraction of that distance in the same time,
+ * which is what stops a journey to a far level tearing away at the start. It holds for any easing curve, so this
+ * asserts on the interpolation rather than on the curve.
+ */
+ @Test
+ fun `SetZoomRatio covers ground evenly however far out the level is`() {
+ setupZoomState(minZoom = 1f, maxZoom = 16f)
+ bindCamera()
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(4f))
+ testDispatcher.scheduler.advanceTimeBy(HALFWAY_MS)
+ val reachedFromOne = viewModel.state.value.zoomRatio / 1f
+
+ testDispatcher.scheduler.advanceUntilIdle()
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(16f))
+ testDispatcher.scheduler.advanceTimeBy(HALFWAY_MS)
+ val reachedFromFour = viewModel.state.value.zoomRatio / 4f
+
+ assertThat(reachedFromOne).isCloseTo(reachedFromFour, RATIO_TOLERANCE)
+ }
+
+ @Test
+ fun `Given a travel under way, when the lens is pinched, then the pinch takes it over`() {
+ setupZoomState(minZoom = 1f, maxZoom = 16f)
+ bindCamera()
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(16f))
+ testDispatcher.scheduler.advanceTimeBy(HALFWAY_MS)
+ val reached = viewModel.state.value.zoomRatio
+
+ viewModel.onEvent(CameraScreenEvents.PinchZoom(zoomFactor = 2f))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo((reached * 2f).coerceAtMost(16f))
+ }
+
// ===========================================================================
// Pinch zoom
// ===========================================================================
@@ -549,6 +627,150 @@ class CameraScreenViewModelTest {
verify { mockCameraControl.setZoomRatio(2f) }
}
+ // ===========================================================================
+ // Zoom ratio (used by the zoom bar)
+ // ===========================================================================
+
+ @Test
+ fun `SetZoomRatio without a bound camera does not change zoom ratio`() {
+ val initialZoom = viewModel.state.value.zoomRatio
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(2f))
+
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(initialZoom)
+ }
+
+ @Test
+ fun `SetZoomRatio travels to the given ratio`() {
+ setupZoomState(minZoom = 0.5f, maxZoom = 10f)
+ bindCamera()
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(5f))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(5f)
+ verify { mockCameraControl.setZoomRatio(5f) }
+ }
+
+ @Test
+ fun `SetZoomRatio clamps to what the lens can reach`() {
+ setupZoomState(minZoom = 1f, maxZoom = 4f)
+ bindCamera()
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(5f))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(4f)
+ }
+
+ /** Otherwise a drag after a level was picked would carry on from where the recording started instead. */
+ @Test
+ fun `Given a ratio was set, when dragged from there, then the drag carries on from it`() {
+ setupZoomState(minZoom = 1f, maxZoom = 5f)
+ bindCamera()
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(2f))
+ testDispatcher.scheduler.advanceUntilIdle()
+ viewModel.onEvent(CameraScreenEvents.LinearZoom(0.5f))
+
+ // Half way in from a base of 2f, rather than the 3f that half way in from 1f would have reached.
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(3.5f)
+ }
+
+ // ===========================================================================
+ // Locking a running recording
+ // ===========================================================================
+
+ /** A lock with no recording behind it would outlive the gesture that asked for it and apply to the next one. */
+ @Test
+ fun `Given nothing is being recorded, when the lock is asked for, then it is not taken`() {
+ viewModel.onEvent(CameraScreenEvents.LockRecording)
+
+ assertThat(viewModel.state.value.isRecordingLocked).isFalse()
+ }
+
+ // ===========================================================================
+ // Pausing a running recording
+ // ===========================================================================
+
+ /** The screen goes by what the recorder reports, so a pause it never honored never shows. */
+ @Test
+ fun `Given nothing is being recorded, when the pause is asked for, then nothing reads as paused`() {
+ viewModel.onEvent(CameraScreenEvents.ToggleRecordingPaused)
+
+ assertThat(viewModel.state.value.isRecordingPaused).isFalse()
+ }
+
+ // ===========================================================================
+ // Zoom range
+ // ===========================================================================
+
+ @Test
+ fun `Given a camera that reports its zoom, when bound, then its reach is published`() {
+ setupZoomState(minZoom = 0.5f, maxZoom = 10f)
+
+ bindCamera()
+
+ assertThat(viewModel.state.value.zoomRange).isEqualTo(0.5f..10f)
+ }
+
+ /** A camera that has not reported yet leaves the range at a single point, which reads as a lens that cannot zoom. */
+ @Test
+ fun `Given a camera that has not reported its zoom, when bound, then the range stays at a single point`() {
+ bindCamera()
+
+ assertThat(viewModel.state.value.zoomRange).isEqualTo(1f..1f)
+ }
+
+ @Test
+ fun `Given nothing has been bound, when asked, then the range is a single point`() {
+ assertThat(viewModel.state.value.zoomRange).isEqualTo(1f..1f)
+ }
+
+ /** A camera reports its zoom when it is ready rather than by the time it is bound, so a late report still counts. */
+ @Test
+ fun `Given a camera that reports its zoom after binding, when it does, then its reach is published`() {
+ bindCamera()
+ assertThat(viewModel.state.value.zoomRange).isEqualTo(1f..1f)
+
+ setupZoomState(minZoom = 0.5f, maxZoom = 10f)
+
+ assertThat(viewModel.state.value.zoomRange).isEqualTo(0.5f..10f)
+ }
+
+ @Test
+ fun `Given a lens whose reach changes, when a different one is bound, then the newer reach is published`() {
+ setupZoomState(minZoom = 0.5f, maxZoom = 10f)
+ bindCamera()
+
+ setupZoomState(minZoom = 1f, maxZoom = 3f)
+
+ assertThat(viewModel.state.value.zoomRange).isEqualTo(1f..3f)
+ }
+
+ /** A newly bound lens comes up at its own zoom rather than carrying over whatever the one before it was at. */
+ @Test
+ fun `Given a lens sitting away from 1x, when bound, then its own zoom is published`() {
+ setupZoomState(minZoom = 1f, maxZoom = 10f, zoomRatio = 5f)
+
+ bindCamera()
+
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(5f)
+ }
+
+ /** The resync happens once per binding, so it cannot pull the lens back from wherever it has since been sent. */
+ @Test
+ fun `Given a bound lens that has been zoomed, when it reports again, then the zoom it was sent to stands`() {
+ setupZoomState(minZoom = 1f, maxZoom = 10f, zoomRatio = 1f)
+ bindCamera()
+
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(4f))
+ testDispatcher.scheduler.advanceUntilIdle()
+ setupZoomState(minZoom = 1f, maxZoom = 8f, zoomRatio = 1f)
+
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(4f)
+ }
+
// ===========================================================================
// Linear zoom (used during video recording)
// ===========================================================================
@@ -628,4 +850,44 @@ class CameraScreenViewModelTest {
verify { mockCameraControl.setZoomRatio(2.5f) }
}
+
+ /**
+ * A lens that reports a narrower reach can leave the base a drag works from behind it. The camera clamps what it is
+ * sent, so the published ratio has to be clamped the same way or the zoom bar reads a level the lens is not at.
+ */
+ @Test
+ fun `Given a drag base above what the lens can reach, when dragged in, then the zoom stays within the lens's reach`() {
+ setupZoomState(minZoom = 1f, maxZoom = 10f)
+ bindCamera()
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(8f))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ setupZoomState(minZoom = 1f, maxZoom = 3f)
+ viewModel.onEvent(CameraScreenEvents.LinearZoom(0.5f))
+
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(3f)
+ }
+
+ /** Clamping the base as well as the result leaves the drag usable from the top of the range rather than pinned to it. */
+ @Test
+ fun `Given a drag base above what the lens can reach, when dragged out, then the drag works from the lens's maximum`() {
+ setupZoomState(minZoom = 1f, maxZoom = 10f)
+ bindCamera()
+ viewModel.onEvent(CameraScreenEvents.SetZoomRatio(8f))
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ setupZoomState(minZoom = 1f, maxZoom = 3f)
+ viewModel.onEvent(CameraScreenEvents.LinearZoom(-0.5f))
+
+ // Half the way out from a base clamped to 3f: 3f + (3f - 1f) * -0.5f
+ assertThat(viewModel.state.value.zoomRatio).isEqualTo(2f)
+ }
+
+ private companion object {
+ /** Far enough into an animation to be clear of both ends without having to know how long it runs for. */
+ private const val HALFWAY_MS = 120L
+
+ /** Two animations sampled a frame apart land a frame's worth of ratio apart, which is the tolerance needed. */
+ private const val RATIO_TOLERANCE = 0.05f
+ }
}
diff --git a/feature/camera/src/test/java/org/signal/camera/hud/CaptureButtonStateTest.kt b/feature/camera/src/test/java/org/signal/camera/hud/CaptureButtonStateTest.kt
new file mode 100644
index 0000000000..62eb115a5d
--- /dev/null
+++ b/feature/camera/src/test/java/org/signal/camera/hud/CaptureButtonStateTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
+import assertk.assertions.isNull
+import assertk.assertions.isTrue
+import org.junit.Test
+
+/**
+ * Covers which look the capture button settles on for a given camera — in particular that a running recording is what it
+ * shows whatever mode it was started from — and what a tap on each of those looks asks for.
+ */
+class CaptureButtonStateTest {
+
+ @Test
+ fun `Given photo mode, when nothing is being recorded, then the button is waiting to take a photo`() {
+ assertThat(stateOf(CaptureButtonMode.PHOTO, isRecording = false, isRecordingLocked = false))
+ .isEqualTo(CaptureButtonState.PHOTO)
+ }
+
+ @Test
+ fun `Given video mode, when nothing is being recorded, then the button is waiting to record`() {
+ assertThat(stateOf(CaptureButtonMode.VIDEO, isRecording = false, isRecordingLocked = false))
+ .isEqualTo(CaptureButtonState.VIDEO)
+ }
+
+ /** A press and hold records from photo mode too, so it is the recording the button shows. */
+ @Test
+ fun `Given a recording that is being held, when in either mode, then the button shows the held recording`() {
+ assertThat(stateOf(CaptureButtonMode.PHOTO, isRecording = true, isRecordingLocked = false))
+ .isEqualTo(CaptureButtonState.RECORDING_HELD)
+ assertThat(stateOf(CaptureButtonMode.VIDEO, isRecording = true, isRecordingLocked = false))
+ .isEqualTo(CaptureButtonState.RECORDING_HELD)
+ }
+
+ @Test
+ fun `Given a recording that is locked, when in either mode, then the button shows the locked recording`() {
+ assertThat(stateOf(CaptureButtonMode.PHOTO, isRecording = true, isRecordingLocked = true))
+ .isEqualTo(CaptureButtonState.RECORDING_LOCKED)
+ assertThat(stateOf(CaptureButtonMode.VIDEO, isRecording = true, isRecordingLocked = true))
+ .isEqualTo(CaptureButtonState.RECORDING_LOCKED)
+ }
+
+ /** A lock only means anything while a recording is running. */
+ @Test
+ fun `Given a lock left behind, when nothing is being recorded, then the button is waiting on its mode`() {
+ assertThat(stateOf(CaptureButtonMode.VIDEO, isRecording = false, isRecordingLocked = true))
+ .isEqualTo(CaptureButtonState.VIDEO)
+ }
+
+ @Test
+ fun `Given a state that is waiting to be used, when asked whether it is recording, then it is not`() {
+ assertThat(CaptureButtonState.PHOTO.isRecording).isFalse()
+ assertThat(CaptureButtonState.VIDEO.isRecording).isFalse()
+ }
+
+ @Test
+ fun `Given a recording, when asked whether it is recording, then it is, held or locked`() {
+ assertThat(CaptureButtonState.RECORDING_HELD.isRecording).isTrue()
+ assertThat(CaptureButtonState.RECORDING_LOCKED.isRecording).isTrue()
+ }
+
+ @Test
+ fun `Given photo mode, when the button is tapped, then a photo is asked for`() {
+ assertThat(CaptureButtonState.PHOTO.tapRequest)
+ .isEqualTo(StandardCameraHudEvents.PhotoCaptureTriggered)
+ }
+
+ /** In video mode a tap records rather than takes a photo, and what it starts needs no holding. */
+ @Test
+ fun `Given video mode, when the button is tapped, then a recording that needs no holding is asked for`() {
+ assertThat(CaptureButtonState.VIDEO.tapRequest)
+ .isEqualTo(StandardCameraHudEvents.VideoCaptureStarted(isLocked = true))
+ }
+
+ @Test
+ fun `Given a recording that needs no holding, when the button is tapped, then it is asked to stop`() {
+ assertThat(CaptureButtonState.RECORDING_LOCKED.tapRequest)
+ .isEqualTo(StandardCameraHudEvents.VideoCaptureStopped)
+ }
+
+ /** The finger holding the recording open is what ends it, so a tap has nothing to ask for. */
+ @Test
+ fun `Given a recording that is being held, when the button is tapped, then nothing is asked for`() {
+ assertThat(CaptureButtonState.RECORDING_HELD.tapRequest).isNull()
+ }
+
+ @Test
+ fun `Given nothing is being recorded, when the gallery's corner is filled, then the gallery is what fills it`() {
+ assertThat(GallerySlotContent.of(CaptureButtonState.PHOTO)).isEqualTo(GallerySlotContent.GALLERY)
+ assertThat(GallerySlotContent.of(CaptureButtonState.VIDEO)).isEqualTo(GallerySlotContent.GALLERY)
+ }
+
+ /** The lock has to be within reach of the finger holding the recording open, so it takes the nearest corner. */
+ @Test
+ fun `Given a recording that is being held, when the gallery's corner is filled, then the lock is what fills it`() {
+ assertThat(GallerySlotContent.of(CaptureButtonState.RECORDING_HELD)).isEqualTo(GallerySlotContent.LOCK)
+ }
+
+ @Test
+ fun `Given a recording that is locked, when the gallery's corner is filled, then the pause is what fills it`() {
+ assertThat(GallerySlotContent.of(CaptureButtonState.RECORDING_LOCKED)).isEqualTo(GallerySlotContent.PAUSE)
+ }
+
+ private fun stateOf(
+ captureButtonMode: CaptureButtonMode,
+ isRecording: Boolean,
+ isRecordingLocked: Boolean
+ ): CaptureButtonState = CaptureButtonState.of(
+ captureButtonMode = captureButtonMode,
+ isRecording = isRecording,
+ isRecordingLocked = isRecordingLocked
+ )
+}
diff --git a/feature/camera/src/test/java/org/signal/camera/hud/CaptureButtonTest.kt b/feature/camera/src/test/java/org/signal/camera/hud/CaptureButtonTest.kt
new file mode 100644
index 0000000000..883b6b3165
--- /dev/null
+++ b/feature/camera/src/test/java/org/signal/camera/hud/CaptureButtonTest.kt
@@ -0,0 +1,316 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import android.app.Application
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.hapticfeedback.HapticFeedback
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performTouchInput
+import assertk.assertThat
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.signal.camera.test.TestTags
+
+/**
+ * Covers what the capture button makes of a gesture: which of a tap, a hold and a drag to the lock it reports, and that
+ * a drag reaching the lock leaves the recording running rather than ending it.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class)
+class CaptureButtonTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val gestures = mutableListOf()
+ private val zoomChanges = mutableListOf()
+ private val haptics = mutableListOf()
+ private var longPressTimeoutMillis = 0L
+
+ /** How near the lock's center takes hold of it: its own radius plus the snap margin beyond that. */
+ private var lockSnapPx = 0f
+
+ /** How far back off the lock the finger has to come to release it, which is further out than [lockSnapPx]. */
+ private var lockSnapOffPx = 0f
+
+ @Test
+ fun `Given a press let go of at once, when it is over, then it was a tap`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performTouchInput {
+ down(center)
+ up()
+ }
+ composeTestRule.waitForIdle()
+
+ assertThat(gestures).isEqualTo(listOf(TAP))
+ }
+
+ @Test
+ fun `Given a press held on, when it is let go of, then it was a hold from beginning to end`() {
+ setContent()
+
+ press()
+ lift()
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START, LONG_PRESS_END))
+ }
+
+ /** Only a drag that carries the whole way reaches the lock. */
+ @Test
+ fun `Given a hold dragged part way to the lock, when it is let go of, then the hold ends and nothing is locked`() {
+ setContent()
+
+ press()
+ dragBy(Offset(x = LOCK_OFFSET.x / 2f, y = 0f))
+ lift()
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START, LONG_PRESS_END))
+ }
+
+ /** Arriving is not taking it: the lock waits for the finger to lift over it. */
+ @Test
+ fun `Given a hold dragged to the lock, when it arrives, then nothing is locked yet`() {
+ setContent()
+
+ press()
+ dragBy(LOCK_OFFSET)
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START))
+ }
+
+ @Test
+ fun `Given a hold that has arrived at the lock, when it is let go of, then the lock is taken`() {
+ setContent()
+
+ press()
+ dragBy(LOCK_OFFSET)
+ lift()
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START, LOCKED))
+ }
+
+ /** One haptic as the finger arrives on the lock, and another as it lifts and takes it. */
+ @Test
+ fun `Given a hold that reached the lock, when it is let go of, then the snap and the lock are both felt`() {
+ setContent()
+
+ press()
+ dragBy(LOCK_OFFSET)
+ lift()
+
+ assertThat(haptics).isEqualTo(listOf(HapticFeedbackType.SegmentTick, HapticFeedbackType.LongPress))
+ }
+
+ /** Only the crossing plays, so the snap fires once however long the finger stays on the lock. */
+ @Test
+ fun `Given a finger resting on the lock, when it moves about on it, then the snap is felt only once`() {
+ setContent()
+
+ press()
+ dragBy(LOCK_OFFSET)
+ dragBy(Offset(x = 1f, y = 1f))
+ dragBy(Offset(x = -1f, y = -1f))
+
+ assertThat(haptics).isEqualTo(listOf(HapticFeedbackType.SegmentTick))
+ }
+
+ @Test
+ fun `Given a hold that never reached the lock, when it is let go of, then nothing is felt`() {
+ setContent()
+
+ press()
+ lift()
+
+ assertThat(haptics).isEmpty()
+ }
+
+ /**
+ * Once the lock has taken hold it keeps it until the finger is clearly off, so a thumb wavering on the boundary does
+ * not turn it on and off.
+ */
+ @Test
+ fun `Given a hold that reached the lock, when it drifts just back off it, then the lock still has it`() {
+ setContent()
+
+ press()
+ dragBy(LOCK_OFFSET)
+ dragBy(Offset(x = (lockSnapPx + lockSnapOffPx) / 2f, y = 0f))
+ lift()
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START, LOCKED))
+ }
+
+ /** Anywhere on the lock is somewhere to let go, not only its exact center. */
+ @Test
+ fun `Given a hold dragged onto the near edge of the lock, when it is let go of, then the lock is taken`() {
+ setContent()
+
+ press()
+ dragBy(Offset(x = LOCK_OFFSET.x + lockSnapPx, y = 0f))
+ lift()
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START, LOCKED))
+ }
+
+ /** A finger that crosses the lock and carries on is headed elsewhere, so it leaves the recording as it was. */
+ @Test
+ fun `Given a hold dragged across the lock and off it, when it is let go of, then nothing is locked`() {
+ setContent()
+
+ press()
+ dragBy(LOCK_OFFSET)
+ dragBy(LOCK_OFFSET)
+ lift()
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START, LONG_PRESS_END))
+ }
+
+ /** Lifting after locking must not end the recording the lock just took over. */
+ @Test
+ fun `Given a hold that reached the lock, when it is let go of, then the hold does not end`() {
+ setContent()
+
+ press()
+ dragBy(LOCK_OFFSET)
+ lift()
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START, LOCKED))
+ }
+
+ /** Only travel toward the lock counts, so a drag the other way gets no closer. */
+ @Test
+ fun `Given a hold dragged away from the lock, when it is let go of, then nothing is locked`() {
+ setContent()
+
+ press()
+ dragBy(Offset(x = -LOCK_OFFSET.x, y = 0f))
+ lift()
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START, LONG_PRESS_END))
+ }
+
+ @Test
+ fun `Given no lock on offer, when a hold is dragged the whole way across, then nothing is locked`() {
+ setContent(lockOffset = Offset.Zero)
+
+ press()
+ dragBy(LOCK_OFFSET)
+ lift()
+
+ assertThat(gestures).isEqualTo(listOf(LONG_PRESS_START, LONG_PRESS_END))
+ }
+
+ /** A drag that has carried past the slop toward the lock is headed there rather than zooming. */
+ @Test
+ fun `Given a hold dragged toward the lock, when it moves, then the zoom is left alone`() {
+ setContent()
+
+ press()
+ dragBy(Offset(x = LOCK_OFFSET.x / 2f, y = -400f))
+ lift()
+
+ assertThat(zoomChanges).isEmpty()
+ }
+
+ @Test
+ fun `Given a hold dragged across the way to the lock, when it moves, then the zoom follows it`() {
+ setContent()
+
+ press()
+ dragBy(Offset(x = 0f, y = -400f))
+ lift()
+
+ assertThat(zoomChanges.any { it > 0f }).isTrue()
+ }
+
+ /** A drag that has only drifted a pixel toward the lock has not set off for it, so the zoom keeps the drag. */
+ @Test
+ fun `Given a hold dragged up with a drift toward the lock, when it moves, then the zoom follows it`() {
+ setContent()
+
+ press()
+ dragBy(Offset(x = -1f, y = -400f))
+ lift()
+
+ assertThat(zoomChanges.any { it > 0f }).isTrue()
+ }
+
+ private fun press() {
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performTouchInput { down(center) }
+ composeTestRule.mainClock.advanceTimeBy(longPressTimeoutMillis + 100L)
+ composeTestRule.waitForIdle()
+ }
+
+ private fun dragBy(offset: Offset) {
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performTouchInput { moveBy(offset) }
+ composeTestRule.waitForIdle()
+ }
+
+ private fun lift() {
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performTouchInput { up() }
+ composeTestRule.waitForIdle()
+ }
+
+ private fun setContent(
+ state: CaptureButtonState = CaptureButtonState.PHOTO,
+ lockOffset: Offset = LOCK_OFFSET
+ ) {
+ composeTestRule.setContent {
+ longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis
+ with(LocalDensity.current) {
+ lockSnapPx = CaptureButtonDimensions.LockDraggableSize.toPx() / 2f + CaptureButtonDimensions.LockSnapMargin.toPx()
+ lockSnapOffPx = lockSnapPx + CaptureButtonDimensions.LockSnapRelease.toPx()
+ }
+
+ val recordingHaptics = remember {
+ object : HapticFeedback {
+ override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {
+ haptics += hapticFeedbackType
+ }
+ }
+ }
+
+ CompositionLocalProvider(LocalHapticFeedback provides recordingHaptics) {
+ CaptureButton(
+ state = state,
+ onTap = { gestures += TAP },
+ onLongPressStart = { gestures += LONG_PRESS_START },
+ onLongPressEnd = { gestures += LONG_PRESS_END },
+ onZoomChange = { zoomChanges += it },
+ onLock = { gestures += LOCKED },
+ lockOffset = lockOffset
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ }
+
+ companion object {
+ private const val TAP = "tap"
+ private const val LONG_PRESS_START = "long_press_start"
+ private const val LONG_PRESS_END = "long_press_end"
+ private const val LOCKED = "locked"
+
+ /** A lock off to the start side, which is where a portrait phone puts it. */
+ private val LOCK_OFFSET = Offset(x = -300f, y = 0f)
+ }
+}
diff --git a/feature/camera/src/test/java/org/signal/camera/hud/RecordingActionButtonsTest.kt b/feature/camera/src/test/java/org/signal/camera/hud/RecordingActionButtonsTest.kt
new file mode 100644
index 0000000000..8707a3d6f5
--- /dev/null
+++ b/feature/camera/src/test/java/org/signal/camera/hud/RecordingActionButtonsTest.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import android.app.Application
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.signal.camera.test.TestTags
+
+/** Covers the button that takes the gallery's place once a recording is running unheld. */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class)
+class RecordingActionButtonsTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private var clicks = 0
+
+ @Test
+ fun `Given a running recording, when the pause is clicked, then the pause is asked for`() {
+ setPauseButtonContent(isPaused = false)
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_PAUSE_BUTTON).performClick()
+
+ assertThat(clicks).isEqualTo(1)
+ }
+
+ /** The same button offers to resume once it has been used, so it raises the same event either way. */
+ @Test
+ fun `Given a paused recording, when the button is clicked, then carrying on is asked for`() {
+ setPauseButtonContent(isPaused = true)
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_PAUSE_BUTTON).performClick()
+
+ assertThat(clicks).isEqualTo(1)
+ }
+
+ private fun setPauseButtonContent(isPaused: Boolean) {
+ composeTestRule.setContent {
+ RecordingPauseButton(
+ isPaused = isPaused,
+ onClick = { clicks++ }
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ }
+}
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
new file mode 100644
index 0000000000..b737c32e56
--- /dev/null
+++ b/feature/camera/src/test/java/org/signal/camera/hud/StandardCameraHudTest.kt
@@ -0,0 +1,383 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import android.app.Application
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalViewConfiguration
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.down
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.up
+import assertk.assertThat
+import assertk.assertions.containsExactly
+import assertk.assertions.isEmpty
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.signal.camera.CameraScreenState
+import org.signal.camera.test.TestTags
+
+/**
+ * Covers what the HUD puts up for a given camera: which of the gallery, the lock and the pause holds the corner beside
+ * the capture button, what a held recording takes out of reach, and what each control asks the camera for.
+ *
+ * The window is pinned taller than 16:9 on purpose. Robolectric's default resolves to the one display the zoom bar has
+ * no room on, which would put half of this out of reach for the wrong reason.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, qualifiers = "w360dp-h760dp")
+class StandardCameraHudTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val events = mutableListOf()
+
+ private var longPressTimeoutMillis = 0L
+
+ //region What holds the corner beside the capture button
+
+ @Test
+ fun `Given nothing is being recorded, when displayed, then the gallery holds the corner`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_GALLERY_BUTTON).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_LOCK_BUTTON).assertDoesNotExist()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_PAUSE_BUTTON).assertDoesNotExist()
+ }
+
+ /** The lock has to be within reach of the finger holding the recording open, which is where the gallery was. */
+ @Test
+ fun `Given a recording that is being held, when displayed, then the lock holds the corner`() {
+ setContent(state = heldRecording())
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_LOCK_BUTTON).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_GALLERY_BUTTON).assertDoesNotExist()
+ }
+
+ @Test
+ fun `Given a recording that is locked, when displayed, then the pause holds the corner`() {
+ setContent(state = lockedRecording())
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_PAUSE_BUTTON).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_LOCK_BUTTON).assertDoesNotExist()
+ }
+
+ //endregion
+
+ //region What a held recording takes out of reach
+
+ @Test
+ fun `Given a recording that is being held, when displayed, then the chrome around it cannot be used`() {
+ setContent(state = heldRecording())
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).assertIsNotEnabled()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsNotEnabled()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).assertIsNotEnabled()
+ }
+
+ /** A locked recording leaves the hand free, so nothing has to be taken away. */
+ @Test
+ fun `Given a recording that is locked, when displayed, then the chrome around it can still be used`() {
+ setContent(state = lockedRecording())
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).assertIsEnabled()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsEnabled()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).assertIsEnabled()
+ }
+
+ @Test
+ fun `Given a recording that is being held, when displayed, then the zoom bar cannot be used`() {
+ setContent(state = heldRecording())
+
+ composeTestRule.onNodeWithText("2").assertIsNotEnabled()
+ }
+
+ @Test
+ fun `Given a recording that is locked, when displayed, then the zoom bar can still be used`() {
+ setContent(state = lockedRecording())
+
+ composeTestRule.onNodeWithText("2").assertIsEnabled()
+ }
+
+ //endregion
+
+ //region What the controls ask for
+
+ @Test
+ fun `Given photo mode, when the capture button is tapped, then a photo is asked for`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.PhotoCaptureTriggered)
+ }
+
+ /** In video mode a tap records rather than takes a photo, and what it starts needs no holding. */
+ @Test
+ fun `Given video mode, when the capture button is tapped, then a recording that needs no holding is asked for`() {
+ setContent(captureButtonMode = CaptureButtonMode.VIDEO)
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.VideoCaptureStarted(isLocked = true))
+ }
+
+ /**
+ * A recording a tap has asked for is not yet one the camera reports as running, so a hold that arrives in that window
+ * is turned away. Were it not, lifting the finger would go on to stop the recording the tap started.
+ */
+ @Test
+ fun `Given a tap has asked for a recording, when a hold arrives before it starts, then nothing is asked to stop`() {
+ setContent(captureButtonMode = CaptureButtonMode.VIDEO)
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performClick()
+ holdAndRelease()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.VideoCaptureStarted(isLocked = true))
+ }
+
+ @Test
+ fun `Given video mode, when the capture button is held and released, then a held recording is asked for and stopped`() {
+ setContent(captureButtonMode = CaptureButtonMode.VIDEO)
+
+ holdAndRelease()
+
+ assertThat(events).containsExactly(
+ StandardCameraHudEvents.VideoCaptureStarted(isLocked = false),
+ StandardCameraHudEvents.VideoCaptureStopped
+ )
+ }
+
+ @Test
+ fun `Given a recording that is locked, when the capture button is tapped, then it is asked to stop`() {
+ setContent(state = lockedRecording())
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.VideoCaptureStopped)
+ }
+
+ @Test
+ fun `Given a recording that is locked, when the pause is clicked, then the pause is asked for`() {
+ setContent(state = lockedRecording())
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_PAUSE_BUTTON).performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.RecordingPauseToggled)
+ }
+
+ @Test
+ fun `when the gallery is clicked, then it is asked for`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_GALLERY_BUTTON).performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.GalleryClick)
+ }
+
+ @Test
+ fun `when the close is clicked, then leaving is asked for`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.CloseClick)
+ }
+
+ @Test
+ fun `when the flash is clicked, then the next flash mode is asked for`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.ToggleFlash)
+ }
+
+ /** Sliding onto the lock is what takes it up, so a tap finds nothing there. */
+ @Test
+ fun `Given a recording that is being held, when the lock is clicked, then nothing is asked for`() {
+ setContent(state = heldRecording())
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_LOCK_BUTTON).performClick()
+
+ assertThat(events).isEmpty()
+ }
+
+ @Test
+ fun `when the camera switch is clicked, then the other camera is asked for`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.SwitchCamera)
+ }
+
+ @Test
+ fun `when a zoom level is picked, then the camera is sent to it`() {
+ setContent()
+
+ composeTestRule.onNodeWithText("2").performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.SetZoomRatio(2f))
+ }
+
+ /** Nothing the chrome offers can be reached while a recording is held, so none of its events go out. */
+ @Test
+ fun `Given a recording that is being held, when the chrome is clicked, then nothing is asked for`() {
+ setContent(state = heldRecording())
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).performClick()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).performClick()
+
+ assertThat(events).isEmpty()
+ }
+
+ //endregion
+
+ //region The recording's own report
+
+ @Test
+ fun `Given nothing is being recorded, when displayed, then no duration is shown`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_DURATION).assertDoesNotExist()
+ }
+
+ @Test
+ fun `Given a recording, when displayed, then how long it has run is shown`() {
+ setContent(state = lockedRecording().copy(recordingDuration = 65_000L))
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_DURATION).assertIsDisplayed()
+ composeTestRule.onNodeWithText("01:05").assertIsDisplayed()
+ }
+
+ //endregion
+
+ //region The same controls on a window too large for the bottom bar
+
+ /**
+ * Anything larger than a portrait phone runs the controls down the side and puts the flash and the switch together in
+ * a pill. They are the same two controls either way, so the same tags find them.
+ */
+ @Test
+ @Config(qualifiers = "w840dp-h1000dp")
+ fun `Given a window too large for the bottom bar, when displayed, then the same controls are up`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_GALLERY_BUTTON).assertIsDisplayed()
+ }
+
+ @Test
+ @Config(qualifiers = "w840dp-h1000dp")
+ fun `Given a window too large for the bottom bar, when the pill is used, then it asks what the bottom bar would`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).performClick()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).performClick()
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.ToggleFlash, StandardCameraHudEvents.SwitchCamera)
+ }
+
+ @Test
+ @Config(qualifiers = "w840dp-h1000dp")
+ fun `Given a window too large for the bottom bar, when a recording is held, then the pill cannot be used`() {
+ setContent(state = heldRecording())
+
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsNotEnabled()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).assertIsNotEnabled()
+ }
+
+ //endregion
+
+ //region How long a recording is let run for
+
+ @Test
+ fun `Given a recording that has run as long as it is allowed, when displayed, then it is asked to stop`() {
+ setContent(state = lockedRecording().copy(recordingDuration = MAX_DURATION), maxRecordingDurationMs = MAX_DURATION)
+
+ assertThat(events).containsExactly(StandardCameraHudEvents.VideoCaptureStopped)
+ }
+
+ @Test
+ fun `Given a recording with time left to run, when displayed, then it is left alone`() {
+ setContent(state = lockedRecording().copy(recordingDuration = MAX_DURATION - 1), maxRecordingDurationMs = MAX_DURATION)
+
+ assertThat(events).isEmpty()
+ }
+
+ /** A limit of zero is no limit, which is what a caller that does not cap the length passes. */
+ @Test
+ fun `Given no limit on the length, when a recording runs past where a limit would be, then it is left alone`() {
+ setContent(state = lockedRecording().copy(recordingDuration = MAX_DURATION), maxRecordingDurationMs = 0L)
+
+ assertThat(events).isEmpty()
+ }
+
+ //endregion
+
+ private fun heldRecording() = CameraScreenState(
+ isRecording = true,
+ isRecordingLocked = false,
+ zoomRange = ZOOM_RANGE
+ )
+
+ private fun lockedRecording() = CameraScreenState(
+ isRecording = true,
+ isRecordingLocked = true,
+ zoomRange = ZOOM_RANGE
+ )
+
+ private fun setContent(
+ state: CameraScreenState = CameraScreenState(zoomRange = ZOOM_RANGE),
+ captureButtonMode: CaptureButtonMode = CaptureButtonMode.PHOTO,
+ maxRecordingDurationMs: Long = 0L
+ ) {
+ composeTestRule.setContent {
+ longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ StandardCameraHud(
+ state = state,
+ emitter = { events += it },
+ captureButtonMode = captureButtonMode,
+ maxRecordingDurationMs = maxRecordingDurationMs
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ }
+
+ private fun holdAndRelease() {
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performTouchInput { down(center) }
+ composeTestRule.mainClock.advanceTimeBy(longPressTimeoutMillis + 100L)
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CAPTURE_BUTTON).performTouchInput { up() }
+ composeTestRule.waitForIdle()
+ }
+
+ companion object {
+ /** A lens that reaches every level, so that the zoom bar has something to put up. */
+ private val ZOOM_RANGE = 0.5f..10f
+
+ private const val MAX_DURATION = 30_000L
+ }
+}
diff --git a/feature/camera/src/test/java/org/signal/camera/hud/ZoomBarLevelTest.kt b/feature/camera/src/test/java/org/signal/camera/hud/ZoomBarLevelTest.kt
new file mode 100644
index 0000000000..c3ff3070de
--- /dev/null
+++ b/feature/camera/src/test/java/org/signal/camera/hud/ZoomBarLevelTest.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import assertk.assertThat
+import assertk.assertions.containsExactly
+import assertk.assertions.isEmpty
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNull
+import org.junit.Test
+import org.signal.camera.CameraDisplay
+
+/**
+ * Covers which zoom levels are worth offering for a given window and lens, and which of them the camera counts as
+ * sitting at.
+ */
+class ZoomBarLevelTest {
+
+ //region What the lens can reach
+
+ @Test
+ fun `Given a lens that reaches every level, when asked what it offers, then all of them are on the bar`() {
+ assertThat(ZoomBarLevel.availableIn(0.5f..10f, ROOMY))
+ .containsExactly(ZoomBarLevel.HALF, ZoomBarLevel.ONE, ZoomBarLevel.TWO, ZoomBarLevel.FIVE)
+ }
+
+ /**
+ * A lens that fuses an ultra-wide in reaches a hardware minimum rather than a round half — a Pixel 9 Pro Fold reports
+ * 0.5058867 — and asking for the half lands there, near enough to read as the half. So the half is offered.
+ */
+ @Test
+ fun `Given a lens that reaches just short of the half, when asked what it offers, then the half is on the bar`() {
+ val available = ZoomBarLevel.availableIn(0.5058867f..20f, ROOMY)
+
+ assertThat(available).containsExactly(ZoomBarLevel.HALF, ZoomBarLevel.ONE, ZoomBarLevel.TWO, ZoomBarLevel.FIVE)
+ assertThat(ZoomBarLevel.of(zoomRatio = 0.5058867f, availableLevels = available)).isEqualTo(ZoomBarLevel.HALF)
+ }
+
+ @Test
+ fun `Given a lens with no ultra wide, when asked what it offers, then the half is withheld`() {
+ assertThat(ZoomBarLevel.availableIn(1f..10f, ROOMY))
+ .containsExactly(ZoomBarLevel.ONE, ZoomBarLevel.TWO, ZoomBarLevel.FIVE)
+ }
+
+ /** A level the lens stops short of would be a tap that changes nothing, so it is not offered. */
+ @Test
+ fun `Given a lens that stops short, when asked what it offers, then the levels past it are withheld`() {
+ assertThat(ZoomBarLevel.availableIn(1f..3f, ROOMY))
+ .containsExactly(ZoomBarLevel.ONE, ZoomBarLevel.TWO)
+ }
+
+ @Test
+ fun `Given a lens that does not quite reach the half, when asked what it offers, then it is withheld`() {
+ assertThat(ZoomBarLevel.availableIn(0.6f..3f, ROOMY))
+ .containsExactly(ZoomBarLevel.ONE, ZoomBarLevel.TWO)
+ }
+
+ @Test
+ fun `Given a lens that does not zoom, when asked what it offers, then only the one it sits at is on the bar`() {
+ assertThat(ZoomBarLevel.availableIn(1f..1f, ROOMY)).containsExactly(ZoomBarLevel.ONE)
+ }
+
+ //endregion
+
+ //region What the window has room for
+
+ /** The viewfinder fills the shortest window edge to edge, leaving the bar nowhere to sit above the capture button. */
+ @Test
+ fun `Given the shortest window, when asked what it offers, then there is room for nothing`() {
+ assertThat(ZoomBarLevel.availableIn(0.5f..10f, CameraDisplay.DISPLAY_16_9)).isEmpty()
+ }
+
+ @Test
+ fun `Given the next window up, when asked what it offers, then there is room for two`() {
+ assertThat(ZoomBarLevel.availableIn(0.5f..10f, CameraDisplay.DISPLAY_18_9))
+ .containsExactly(ZoomBarLevel.ONE, ZoomBarLevel.TWO)
+ }
+
+ @Test
+ fun `Given a window with room to spare, when asked what it offers, then every level the lens reaches is on the bar`() {
+ val roomy = listOf(
+ CameraDisplay.DISPLAY_19_9,
+ CameraDisplay.DISPLAY_20_9,
+ CameraDisplay.DISPLAY_6_5,
+ CameraDisplay.LARGE_PORTRAIT,
+ CameraDisplay.LARGE_LANDSCAPE
+ )
+
+ roomy.forEach { cameraDisplay ->
+ assertThat(ZoomBarLevel.availableIn(0.5f..10f, cameraDisplay), name = cameraDisplay.name)
+ .containsExactly(ZoomBarLevel.HALF, ZoomBarLevel.ONE, ZoomBarLevel.TWO, ZoomBarLevel.FIVE)
+ }
+ }
+
+ /** Room for two is a ceiling, not a promise: a lens that reaches only one still offers only that one. */
+ @Test
+ fun `Given the next window up and a lens that stops short, when asked what it offers, then only the reachable one is on the bar`() {
+ assertThat(ZoomBarLevel.availableIn(1f..1.5f, CameraDisplay.DISPLAY_18_9))
+ .containsExactly(ZoomBarLevel.ONE)
+ }
+
+ //endregion
+
+ //region Which level the camera is sitting at
+
+ @Test
+ fun `Given the camera at a level, when asked which is showing, then it is that one`() {
+ assertThat(ZoomBarLevel.of(zoomRatio = 2f, availableLevels = ALL)).isEqualTo(ZoomBarLevel.TWO)
+ }
+
+ /** A camera lands where its hardware allows rather than exactly where it was sent. */
+ @Test
+ fun `Given the camera just short of a level, when asked which is showing, then it is still that one`() {
+ assertThat(ZoomBarLevel.of(zoomRatio = 4.95f, availableLevels = ALL)).isEqualTo(ZoomBarLevel.FIVE)
+ assertThat(ZoomBarLevel.of(zoomRatio = 1.01f, availableLevels = ALL)).isEqualTo(ZoomBarLevel.ONE)
+ }
+
+ /** A ratio between two levels, which is where a pinch tends to leave the camera. */
+ @Test
+ fun `Given the camera between two levels, when asked which is showing, then none of them is`() {
+ assertThat(ZoomBarLevel.of(zoomRatio = 3.4f, availableLevels = ALL)).isNull()
+ }
+
+ /** The tolerance is a fraction of the level, so at the long end it does not stretch to a neighbour. */
+ @Test
+ fun `Given the camera well short of a level, when asked which is showing, then none of them is`() {
+ assertThat(ZoomBarLevel.of(zoomRatio = 4.5f, availableLevels = ALL)).isNull()
+ }
+
+ /** A ratio reached some other way cannot select a level the user was never offered. */
+ @Test
+ fun `Given the camera at a level the bar withheld, when asked which is showing, then none of them is`() {
+ val withoutHalf = ZoomBarLevel.availableIn(1f..10f, ROOMY)
+
+ assertThat(ZoomBarLevel.of(zoomRatio = 0.5f, availableLevels = withoutHalf)).isNull()
+ }
+
+ /** The window can withhold a level the lens reaches, and a pinch can still send the camera there. */
+ @Test
+ fun `Given the camera at a level the window had no room for, when asked which is showing, then none of them is`() {
+ val roomForTwo = ZoomBarLevel.availableIn(0.5f..10f, CameraDisplay.DISPLAY_18_9)
+
+ assertThat(ZoomBarLevel.of(zoomRatio = 5f, availableLevels = roomForTwo)).isNull()
+ }
+
+ //endregion
+
+ /** The half is labeled as a ratio rather than rounded to the whole number below it. */
+ @Test
+ fun `Given the half, when read off the bar, then it says what it is`() {
+ assertThat(ZoomBarLevel.HALF.label).isEqualTo(".5")
+ assertThat(ZoomBarLevel.ONE.label).isEqualTo("1")
+ }
+
+ companion object {
+ private val ALL = ZoomBarLevel.entries
+
+ /** A window with room for every level, so a test of the lens only has to vary the lens. */
+ private val ROOMY = CameraDisplay.DISPLAY_20_9
+ }
+}
diff --git a/feature/camera/src/test/java/org/signal/camera/hud/ZoomBarTest.kt b/feature/camera/src/test/java/org/signal/camera/hud/ZoomBarTest.kt
new file mode 100644
index 0000000000..747a8f62e8
--- /dev/null
+++ b/feature/camera/src/test/java/org/signal/camera/hud/ZoomBarTest.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.camera.hud
+
+import android.app.Application
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.assertIsNotSelected
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import assertk.assertThat
+import assertk.assertions.containsExactly
+import assertk.assertions.isEmpty
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.signal.camera.CameraDisplay
+
+/**
+ * Covers what the zoom bar puts on screen for a given lens and window, which of its levels reads as the one showing, and
+ * what a tap on one of them asks for.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class)
+class ZoomBarTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val picked = mutableListOf()
+
+ @Test
+ fun `Given a lens that reaches every level, when displayed, then each one is on the bar`() {
+ setContent()
+
+ composeTestRule.onNodeWithText(".5").assertIsDisplayed()
+ composeTestRule.onNodeWithText("1").assertIsDisplayed()
+ composeTestRule.onNodeWithText("2").assertIsDisplayed()
+ composeTestRule.onNodeWithText("5").assertIsDisplayed()
+ }
+
+ @Test
+ fun `Given the camera at a level, when displayed, then that level is the one showing`() {
+ setContent(zoomRatio = 2f)
+
+ composeTestRule.onNodeWithText("2").assertIsSelected()
+ composeTestRule.onNodeWithText("1").assertIsNotSelected()
+ }
+
+ /** A ratio between two levels, which is where a pinch tends to leave the camera. */
+ @Test
+ fun `Given the camera between two levels, when displayed, then none of them is showing`() {
+ setContent(zoomRatio = 3.4f)
+
+ composeTestRule.onNodeWithText("2").assertIsNotSelected()
+ composeTestRule.onNodeWithText("5").assertIsNotSelected()
+ }
+
+ @Test
+ fun `when a level is picked, then it is asked for`() {
+ setContent()
+
+ composeTestRule.onNodeWithText("5").performClick()
+
+ assertThat(picked).containsExactly(ZoomBarLevel.FIVE)
+ }
+
+ @Test
+ fun `Given a lens that stops short, when displayed, then the levels past it are not on the bar`() {
+ setContent(zoomRange = 1f..3f)
+
+ composeTestRule.onNodeWithText("1").assertIsDisplayed()
+ composeTestRule.onNodeWithText("2").assertIsDisplayed()
+ composeTestRule.onNodeWithText(".5").assertDoesNotExist()
+ composeTestRule.onNodeWithText("5").assertDoesNotExist()
+ }
+
+ /** The shortest window is filled by the viewfinder, leaving the bar nowhere to sit. */
+ @Test
+ fun `Given a window with no room, when displayed, then no bar is offered`() {
+ setContent(cameraDisplay = CameraDisplay.DISPLAY_16_9)
+
+ composeTestRule.onNodeWithText("1").assertDoesNotExist()
+ composeTestRule.onNodeWithText("2").assertDoesNotExist()
+ }
+
+ @Test
+ fun `Given a lens that does not zoom, when displayed, then no bar is offered`() {
+ setContent(zoomRange = 1f..1f)
+
+ composeTestRule.onNodeWithText("1").assertDoesNotExist()
+ }
+
+ /** A bar that has faded out cannot be used, however much of it is still on screen. */
+ @Test
+ fun `Given the bar is not showing, when a level is picked, then nothing is asked for`() {
+ setContent(visible = false)
+
+ composeTestRule.onNodeWithText("2").assertIsNotEnabled()
+ composeTestRule.onNodeWithText("2").performClick()
+
+ assertThat(picked).isEmpty()
+ }
+
+ private fun setContent(
+ zoomRatio: Float = 1f,
+ zoomRange: ClosedFloatingPointRange = 0.5f..10f,
+ cameraDisplay: CameraDisplay = CameraDisplay.DISPLAY_20_9,
+ visible: Boolean = true
+ ) {
+ composeTestRule.setContent {
+ ZoomBar(
+ zoomRatio = zoomRatio,
+ zoomRange = zoomRange,
+ cameraDisplay = cameraDisplay,
+ onZoomLevelClick = { picked += it },
+ visible = visible
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ }
+}
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 e8eb712168..0a8583f79b 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
@@ -75,6 +75,7 @@ import org.signal.camera.CameraScreenViewModel
import org.signal.camera.CameraXUtil
import org.signal.camera.VideoCaptureResult
import org.signal.camera.VideoOutput
+import org.signal.camera.hud.CaptureButtonMode
import org.signal.camera.hud.GalleryThumbnailButton
import org.signal.camera.hud.StandardCameraHud
import org.signal.camera.hud.StandardCameraHudEvents
@@ -344,6 +345,8 @@ private fun CameraFragment.Controller.onCameraXScreenEvent(event: CameraXScreenE
is CameraXScreenEvents.ImageCaptured -> onImageCaptured(event.data, event.width, event.height)
is CameraXScreenEvents.VideoCaptured -> onVideoCaptured(event.fd, event.durationMs)
is CameraXScreenEvents.QrCodeFound -> onQrCodeFound(event.data)
+ // The chrome the legacy hosts put up does not move for a recording, so there is nothing to tell them.
+ is CameraXScreenEvents.RecordingStateChanged -> Unit
CameraXScreenEvents.VideoCaptureError -> onVideoCaptureError()
CameraXScreenEvents.GalleryClicked -> onGalleryClicked()
CameraXScreenEvents.CameraCloseClicked -> onCameraCloseClicked()
@@ -370,7 +373,9 @@ data class CameraXScreenState(
val isVideoEnabled: Boolean = true,
val isQrScanEnabled: Boolean = false,
val controlsVisible: Boolean = true,
- val selectedMediaCount: Int = 0
+ val selectedMediaCount: Int = 0,
+ /** Which kind of capture the capture button offers, which is the mode the flow's bottom bar has selected. */
+ val captureButtonMode: CaptureButtonMode = CaptureButtonMode.PHOTO
)
/** A descriptor to record into, paired with the duration cap that the descriptor actually supports. */
@@ -516,6 +521,11 @@ fun CameraXScreen(
}
}
+ LaunchedEffect(cameraViewModel) {
+ snapshotFlow { cameraState.isRecording }
+ .collect { isRecording -> onEvent(CameraXScreenEvents.RecordingStateChanged(isRecording)) }
+ }
+
LaunchedEffect(cameraViewModel, state.isQrScanEnabled) {
if (state.isQrScanEnabled) {
cameraViewModel.qrCodeDetected.collect { qrCode ->
@@ -600,6 +610,7 @@ fun CameraXScreen(
state = cameraState,
modifier = Modifier.padding(bottom = if (isPortraitPhone) hudBottomPaddingInsideViewport else 0.dp),
maxRecordingDurationMs = activeRecordingMaxDurationMs,
+ captureButtonMode = state.captureButtonMode,
hasAudioPermission = { context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED },
emitter = { event ->
handleHudEvent(
@@ -727,12 +738,18 @@ private fun handleHudEvent(
}
is StandardCameraHudEvents.VideoCaptureStarted -> {
+ if (cameraViewModel.hasActiveRecording) {
+ Log.d(TAG, "Ignoring a recording asked for while one is already running")
+ return
+ }
+
val recording = if (Build.VERSION.SDK_INT >= 26 && isVideoEnabled) createVideoFileDescriptor() else null
if (recording != null) {
cameraViewModel.startRecording(
context = context,
output = VideoOutput.FileDescriptorOutput(recording.parcelFd),
+ isRecordingLocked = event.isLocked,
onVideoCaptured = { result ->
handleVideoCaptured(result, releaseVideoFileDescriptor, onVideoCaptureFailed, onEvent)
}
@@ -747,6 +764,14 @@ private fun handleHudEvent(
cameraViewModel.stopRecording()
}
+ is StandardCameraHudEvents.VideoCaptureLocked -> {
+ cameraViewModel.onEvent(CameraScreenEvents.LockRecording)
+ }
+
+ is StandardCameraHudEvents.RecordingPauseToggled -> {
+ cameraViewModel.onEvent(CameraScreenEvents.ToggleRecordingPaused)
+ }
+
is StandardCameraHudEvents.GalleryClick -> {
onEvent(CameraXScreenEvents.GalleryClicked)
}
@@ -771,6 +796,10 @@ private fun handleHudEvent(
cameraViewModel.onEvent(CameraScreenEvents.LinearZoom(event.zoomLevel))
}
+ is StandardCameraHudEvents.SetZoomRatio -> {
+ cameraViewModel.onEvent(CameraScreenEvents.SetZoomRatio(event.zoomRatio))
+ }
+
is StandardCameraHudEvents.AudioPermissionRequired -> {
onRequestMicPermission()
}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXScreenEvents.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXScreenEvents.kt
index e216ef7fcd..7d51d71fca 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXScreenEvents.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXScreenEvents.kt
@@ -16,6 +16,9 @@ sealed interface CameraXScreenEvents {
*/
class VideoCaptured(val fd: SeekableFileDescriptor, val durationMs: Long) : CameraXScreenEvents
class QrCodeFound(val data: String) : CameraXScreenEvents
+
+ data class RecordingStateChanged(val isRecording: Boolean) : CameraXScreenEvents
+
data object VideoCaptureError : CameraXScreenEvents
data object GalleryClicked : CameraXScreenEvents
data object CameraCloseClicked : CameraXScreenEvents
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt
index 9288af8a99..53011b52ab 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt
@@ -9,6 +9,7 @@ import androidx.camera.viewfinder.core.ImplementationMode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview
+import org.signal.camera.hud.CaptureButtonMode
import org.signal.core.ui.compose.Previews
import org.signal.mediasend.PreviewMediaConstraints
@@ -25,11 +26,12 @@ internal fun MediaCameraCaptureScreen(
val permissions = rememberCameraPermissionController(isVideoEnabled)
CameraXScreen(
- state = remember(state.selectedMedia) {
+ state = remember(state.selectedMedia, state.selectedCameraMode) {
CameraXScreenState(
isVideoEnabled = isVideoEnabled,
isQrScanEnabled = true,
- selectedMediaCount = state.selectedMedia.size
+ selectedMediaCount = state.selectedMedia.size,
+ captureButtonMode = if (state.selectedCameraMode == MediaCaptureMode.VIDEO) CaptureButtonMode.VIDEO else CaptureButtonMode.PHOTO
)
},
onEvent = { event -> onEvent(MediaCaptureScreenEvents.Camera(event)) },
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureMode.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureMode.kt
new file mode 100644
index 0000000000..4cce2c9c23
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureMode.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.screens.capture
+
+import androidx.annotation.StringRes
+import org.signal.mediasend.MediaSendRoute
+import org.signal.mediasend.R
+import org.signal.mediasend.test.TestTags
+
+/**
+ * A way of making media that the capture screen can offer. Which of these a given flow offers, and the order the bar
+ * shows them in, is [MediaCaptureState.availableCaptureModes].
+ *
+ * @param captureScreen Where navigation has to be for the mode to be usable. [PHOTO] and [VIDEO] are two modes of the
+ * one camera, so they share a screen.
+ */
+internal enum class MediaCaptureMode(
+ val captureScreen: MediaSendRoute.Capture,
+ @param:StringRes val label: Int,
+ val testTag: String
+) {
+ VIDEO(MediaSendRoute.Capture.Camera, R.string.MediaCaptureScreen__video, TestTags.MEDIA_CAPTURE_VIDEO_TOGGLE),
+ PHOTO(MediaSendRoute.Capture.Camera, R.string.MediaCaptureScreen__photo, TestTags.MEDIA_CAPTURE_PHOTO_TOGGLE),
+ TEXT_STORY(MediaSendRoute.Capture.TextStory, R.string.MediaCaptureScreen__text, TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE)
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureModeBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureModeBar.kt
new file mode 100644
index 0000000000..9f874d50dc
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureModeBar.kt
@@ -0,0 +1,294 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.screens.capture
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.signal.core.ui.compose.Buttons
+import org.signal.core.ui.compose.NightPreview
+import org.signal.core.ui.compose.PhonePortraitNightPreview
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.theme.SignalTheme
+import org.signal.mediasend.R
+import org.signal.mediasend.test.TestTags
+import kotlin.math.roundToInt
+
+private val MODE_BAR_SHAPE = RoundedCornerShape(percent = 50)
+
+/** Read by whatever shares the bar's row, so it can be lined up with the bar. */
+internal val MODE_BAR_HEIGHT = 44.dp
+
+/** How far the modes are inset from the edges of the bar they sit on. */
+private val MODE_BAR_PADDING = 6.dp
+
+/** No bounce, so a mode does not wobble once it has landed under the highlight. */
+private val MODE_SETTLE_SPEC = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)
+
+/**
+ * Lets the user switch between the capture modes the flow offers, which are
+ * [MediaCaptureState.availableCaptureModes].
+ *
+ * The highlight is fixed to the center of the screen and the bar slides so the selected mode is the one under it. A mode
+ * can be picked by tapping it or by swiping the bar until it is under the highlight; a swipe only selects once it is
+ * released, so the user can slide back and forth before committing.
+ */
+@Composable
+internal fun MediaCaptureModeBar(
+ availableCaptureModes: List,
+ selectedCaptureMode: MediaCaptureMode,
+ onEvent: (MediaCaptureScreenEvents) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+ val selectedIndex = availableCaptureModes.indexOf(selectedCaptureMode).coerceAtLeast(0)
+
+ // Which mode is under the highlight, as an index into availableCaptureModes, fractional while a swipe has the bar
+ // between two of them.
+ val centeredMode = remember(availableCaptureModes) { Animatable(selectedIndex.toFloat()) }
+
+ // How far a swipe has to travel to move the bar along by a mode. Only known once the bar has been laid out.
+ var modeWidth by remember { mutableFloatStateOf(0f) }
+
+ LaunchedEffect(availableCaptureModes, selectedIndex) {
+ centeredMode.animateTo(selectedIndex.toFloat(), MODE_SETTLE_SPEC)
+ }
+
+ Layout(
+ content = {
+ Box(modifier = Modifier.background(color = colorResource(R.color.MediaSend_controls_color), shape = MODE_BAR_SHAPE))
+
+ Box(
+ modifier = Modifier
+ .onSizeChanged { modeWidth = it.width.toFloat() }
+ .background(color = SignalTheme.colors.colorTransparent3, shape = MODE_BAR_SHAPE)
+ )
+
+ availableCaptureModes.forEach { captureMode ->
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .clip(MODE_BAR_SHAPE)
+ .selectable(
+ selected = captureMode == selectedCaptureMode,
+ onClick = { onEvent(MediaCaptureScreenEvents.CaptureModeSelected(captureMode)) }
+ )
+ .padding(horizontal = 12.dp)
+ .testTag(captureMode.testTag)
+ ) {
+ Text(
+ text = stringResource(captureMode.label),
+ color = SignalTheme.colors.colorOnCustom,
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ },
+ modifier = modifier
+ .height(MODE_BAR_HEIGHT)
+ .selectableGroup()
+ .testTag(TestTags.MEDIA_CAPTURE_MODE_BAR)
+ .pointerInput(availableCaptureModes, isRtl) {
+ detectHorizontalDragGestures(
+ onHorizontalDrag = { _, dragAmount ->
+ if (modeWidth > 0f) {
+ val slid = if (isRtl) dragAmount else -dragAmount
+ coroutineScope.launch {
+ centeredMode.snapTo((centeredMode.value + slid / modeWidth).coerceIn(0f, availableCaptureModes.lastIndex.toFloat()))
+ }
+ }
+ },
+ onDragEnd = {
+ val landedOn = centeredMode.value.roundToInt().coerceIn(availableCaptureModes.indices)
+ coroutineScope.launch { centeredMode.animateTo(landedOn.toFloat(), MODE_SETTLE_SPEC) }
+ onEvent(MediaCaptureScreenEvents.CaptureModeSelected(availableCaptureModes[landedOn]))
+ },
+ onDragCancel = {
+ val landedOn = centeredMode.value.roundToInt().coerceIn(availableCaptureModes.indices)
+ coroutineScope.launch { centeredMode.animateTo(landedOn.toFloat(), MODE_SETTLE_SPEC) }
+ }
+ )
+ }
+ ) { measurables, constraints ->
+ val barHeight = MODE_BAR_HEIGHT.roundToPx()
+ val barPadding = MODE_BAR_PADDING.roundToPx()
+ val modeHeight = barHeight - barPadding * 2
+ val modeWidthLimit = if (constraints.hasBoundedWidth) (constraints.maxWidth - barPadding * 2) / availableCaptureModes.size else Constraints.Infinity
+
+ val modes = measurables.drop(2).map { it.measure(Constraints(maxWidth = modeWidthLimit, minHeight = modeHeight, maxHeight = modeHeight)) }
+ val measuredModeWidth = modes.maxOf { it.width }
+ val barWidth = measuredModeWidth * modes.size + barPadding * 2
+ val containerWidth = if (constraints.hasBoundedWidth) constraints.maxWidth else barWidth
+
+ val bar = measurables[0].measure(Constraints.fixed(barWidth, barHeight))
+ val highlight = measurables[1].measure(Constraints.fixed(measuredModeWidth, modeHeight))
+
+ layout(width = containerWidth, height = barHeight) {
+ // The bar slides by however far the centered mode is from the middle of the container, which is where the
+ // highlight always sits.
+ val slide = containerWidth / 2f - barPadding - (centeredMode.value + 0.5f) * measuredModeWidth
+
+ bar.placeRelative(x = slide.roundToInt(), y = 0)
+ highlight.placeRelative(x = (containerWidth - measuredModeWidth) / 2, y = barPadding)
+
+ modes.forEachIndexed { index, mode ->
+ mode.placeRelative(
+ x = (slide + barPadding + index * measuredModeWidth + (measuredModeWidth - mode.width) / 2f).roundToInt(),
+ y = barPadding
+ )
+ }
+ }
+ }
+}
+
+@NightPreview
+@Composable
+private fun MediaCaptureModeBarPhotoPreview() {
+ MediaCaptureModeBarPreview(MediaCaptureMode.PHOTO)
+}
+
+@NightPreview
+@Composable
+private fun MediaCaptureModeBarVideoPreview() {
+ MediaCaptureModeBarPreview(MediaCaptureMode.VIDEO)
+}
+
+@NightPreview
+@Composable
+private fun MediaCaptureModeBarTextStoryPreview() {
+ MediaCaptureModeBarPreview(MediaCaptureMode.TEXT_STORY)
+}
+
+/**
+ * Every mode on offer, opened on [initialCaptureMode] and switchable from there, so each preview shows both a selection
+ * and where the bar comes to rest for it.
+ *
+ * The bar centers its selection on whatever width it is given, so the preview is pinned to a phone's width rather than
+ * left to wrap its content.
+ */
+@Composable
+private fun MediaCaptureModeBarPreview(initialCaptureMode: MediaCaptureMode) {
+ var selectedCaptureMode: MediaCaptureMode by remember { mutableStateOf(initialCaptureMode) }
+
+ Previews.Preview {
+ Box(
+ modifier = Modifier
+ .width(360.dp)
+ .background(color = Color.Black)
+ ) {
+ MediaCaptureModeBar(
+ availableCaptureModes = MediaCaptureMode.entries,
+ selectedCaptureMode = selectedCaptureMode,
+ onEvent = { event ->
+ if (event is MediaCaptureScreenEvents.CaptureModeSelected) {
+ selectedCaptureMode = event.mode
+ }
+ }
+ )
+ }
+ }
+}
+
+/**
+ * A harness for the sliding itself. Run it with the interactive preview — a swipe drives the animation imperatively, so
+ * the animation inspector has nothing to show:
+ *
+ * - Drag anywhere along the bar to slide the modes, and let go to pick whatever is under the highlight.
+ * - Tap a mode either side of the highlight to have the bar slide it in.
+ * - The buttons pick a mode from outside the bar, which is the path navigation takes to and from the text story editor.
+ *
+ * The readout is what the bar has asked for and how many times, so a swipe that has not been released leaves it alone no
+ * matter how far the modes have moved.
+ */
+@PhonePortraitNightPreview
+@Composable
+private fun MediaCaptureModeBarInteractivePreview() {
+ var selectedCaptureMode: MediaCaptureMode by remember { mutableStateOf(MediaCaptureMode.PHOTO) }
+ var selectionCount: Int by remember { mutableIntStateOf(0) }
+
+ Previews.Preview {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = Color.Black)
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.align(Alignment.Center)
+ ) {
+ Text(
+ text = "Asked for ${selectedCaptureMode.name} ($selectionCount times)",
+ color = Color.White,
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ MediaCaptureMode.entries.forEach { captureMode ->
+ Buttons.Small(onClick = { selectedCaptureMode = captureMode }) {
+ Text(text = captureMode.name)
+ }
+ }
+ }
+ }
+
+ MediaCaptureModeBar(
+ availableCaptureModes = MediaCaptureMode.entries,
+ selectedCaptureMode = selectedCaptureMode,
+ onEvent = { event ->
+ if (event is MediaCaptureScreenEvents.CaptureModeSelected) {
+ selectedCaptureMode = event.mode
+ selectionCount++
+ }
+ },
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ }
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureRepository.kt
index 580e4ec033..cb2f5b0cdc 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureRepository.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureRepository.kt
@@ -12,7 +12,9 @@ import kotlinx.coroutines.withContext
import org.signal.core.models.media.Media
import org.signal.core.util.ContentTypeUtil
import org.signal.core.util.SeekableFileDescriptor
+import org.signal.core.util.Stopwatch
import org.signal.core.util.contentproviders.BlobProvider
+import org.signal.core.util.logging.Log
import org.signal.mediasend.MediaSendDependencies
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
import java.io.FileInputStream
@@ -37,11 +39,15 @@ internal class MediaCaptureRepository(
buildCapturedMedia(uri, ContentTypeUtil.IMAGE_JPEG, width, height, data.size.toLong())
} catch (e: IOException) {
+ Log.w(TAG, "Failed to write out a captured image", e)
null
}
}
/**
+ * Copies the recording into a blob of its own. The recorder writes into an ephemerally-keyed scratch file that is
+ * deleted when its descriptor closes, so the bytes have to be re-encrypted under the attachment secret to outlive it.
+ *
* @param fd The recording, which is closed here whether or not it could be written out.
* @return The captured recording, or null if it could not be written out.
*/
@@ -49,16 +55,25 @@ internal class MediaCaptureRepository(
try {
fd.use { descriptor ->
FileInputStream(descriptor.fileDescriptor).use { stream ->
+ val stopwatch = Stopwatch("captured-video-copy")
+
val length = stream.channel.size()
+ stopwatch.split("length")
+
val uri = blobs
.forData(stream, length)
.withMimeType(VideoConstants.RECORDED_VIDEO_CONTENT_TYPE)
.createForSingleSessionOnDisk(context)
+ stopwatch.split("blob")
+
+ Log.d(TAG, "Copied a recording into a blob. bytes: $length")
+ stopwatch.stop(TAG)
buildCapturedMedia(uri, VideoConstants.RECORDED_VIDEO_CONTENT_TYPE, 0, 0, length)
}
}
} catch (e: IOException) {
+ Log.w(TAG, "Failed to write out a captured recording", e)
null
}
}
@@ -80,4 +95,8 @@ internal class MediaCaptureRepository(
fileName = null
)
}
+
+ companion object {
+ private val TAG = Log.tag(MediaCaptureRepository::class)
+ }
}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt
index 37168cfce9..3f817b13b7 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt
@@ -5,56 +5,40 @@
package org.signal.mediasend.screens.capture
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
-import androidx.compose.foundation.BorderStroke
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.SegmentedButton
-import androidx.compose.material3.SegmentedButtonDefaults
-import androidx.compose.material3.SingleChoiceSegmentedButtonRow
-import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.pluralStringResource
-import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.camera.CameraDisplay
-import org.signal.core.models.media.Media
import org.signal.core.ui.compose.NightPreview
import org.signal.core.ui.compose.Previews
-import org.signal.core.ui.compose.SignalIcons
-import org.signal.core.ui.compose.theme.SignalTheme
-import org.signal.glide.compose.GlideImage
-import org.signal.glide.decryptableuri.DecryptableUri
+import org.signal.core.ui.compose.endFadingEdge
import org.signal.mediasend.MediaSendFlowActivityContract
import org.signal.mediasend.MediaSendRoute
import org.signal.mediasend.PreviewMediaConstraints
-import org.signal.mediasend.R
import org.signal.mediasend.screens.edit.rememberPreviewMedia
+import org.signal.mediasend.screens.shared.NEXT_BUTTON_CIRCLE_SIZE
+import org.signal.mediasend.screens.shared.NEXT_BUTTON_HEIGHT
+import org.signal.mediasend.screens.shared.NEXT_BUTTON_TOUCH_TARGET
+import org.signal.mediasend.screens.shared.NextButton
+import org.signal.mediasend.screens.shared.chatColorFor
import org.signal.mediasend.test.TestTags
/**
@@ -63,6 +47,18 @@ import org.signal.mediasend.test.TestTags
private const val CAMERA_Z_INDEX = 0f
private const val TEXT_STORY_Z_INDEX = 1f
+/** The row the mode bar and the next button share, kept tall enough that neither moves when the other comes or goes. */
+private val BOTTOM_CONTROLS_HEIGHT = NEXT_BUTTON_HEIGHT
+
+/** Drops the mode bar onto the centerline of the next button's circle, which is what it reads as lined up with. */
+private val MODE_BAR_BOTTOM_INSET = (NEXT_BUTTON_TOUCH_TARGET - MODE_BAR_HEIGHT) / 2
+
+/** How far back from the next button the bar has finished fading, so no legible label reaches it. */
+private val MODE_BAR_FADE_WIDTH = 40.dp
+
+/** How long the bar and the button take to come and go, which the bar's fade follows them over. */
+private const val CONTROL_FADE_DURATION_MS = 150
+
/**
* Screen that allows user to capture the media they will send using a camera or text story
*/
@@ -92,170 +88,80 @@ internal fun MediaCaptureScreen(
}
}
- if (state.canDisplayBottomBar) {
- MediaCaptureBottomBar(
- canDisplayMediaBar = state.canDisplayMediaBar,
- canDisplayToggleSwitch = state.canDisplayToggleSwitch,
- selectedCaptureScreen = state.selectedCaptureScreen,
- selectedMedia = state.selectedMedia,
- onEvent = onEvent,
- modifier = Modifier
- .align(Alignment.BottomCenter)
- .navigationBarsPadding()
- )
- }
- }
-}
-
-@Composable
-fun MediaCaptureBottomBar(
- canDisplayToggleSwitch: Boolean,
- canDisplayMediaBar: Boolean,
- selectedCaptureScreen: MediaSendRoute.Capture,
- selectedMedia: List,
- onEvent: (MediaCaptureScreenEvents) -> Unit,
- modifier: Modifier = Modifier
-) {
- if (canDisplayToggleSwitch) {
- MediaCaptureToggleBar(
- selectedCaptureScreen = selectedCaptureScreen,
+ MediaCaptureBottomControls(
+ state = state,
onEvent = onEvent,
- modifier = modifier
- )
- } else if (canDisplayMediaBar && selectedMedia.isNotEmpty()) {
- MediaCaptureMediaBar(
- selectedMedia = selectedMedia,
- onEvent = onEvent,
- modifier = modifier
- )
- }
-}
-
-@Composable
-private fun MediaCaptureToggleBar(
- selectedCaptureScreen: MediaSendRoute.Capture,
- onEvent: (MediaCaptureScreenEvents) -> Unit,
- modifier: Modifier = Modifier
-) {
- val cameraDisplay = CameraDisplay.rememberCameraDisplay(isLandscape = false)
-
- SingleChoiceSegmentedButtonRow(
- modifier = modifier
- .padding(bottom = cameraDisplay.getToggleBottomMargin().dp)
- .height(44.dp)
- .background(color = colorResource(R.color.MediaSend_controls_color), shape = RoundedCornerShape(50))
- .padding(horizontal = 6.dp, vertical = 6.dp)
- ) {
- SegmentedBarButton(
- selected = selectedCaptureScreen == MediaSendRoute.Capture.Camera,
- onClick = { onEvent(MediaCaptureScreenEvents.ShowCamera) },
- modifier = Modifier.testTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE)
- ) {
- Text(text = stringResource(R.string.MediaCaptureScreen__camera))
- }
-
- SegmentedBarButton(
- selected = selectedCaptureScreen == MediaSendRoute.Capture.TextStory,
- onClick = { onEvent(MediaCaptureScreenEvents.ShowTextStory) },
- modifier = Modifier.testTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE)
- ) {
- Text(text = stringResource(R.string.MediaCaptureScreen__text))
- }
- }
-}
-
-@Composable
-private fun SingleChoiceSegmentedButtonRowScope.SegmentedBarButton(
- selected: Boolean,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
- content: @Composable () -> Unit
-) {
- SegmentedButton(
- selected = selected,
- onClick = onClick,
- modifier = modifier,
- shape = RoundedCornerShape(percent = 50),
- icon = {},
- border = BorderStroke(0.dp, Color.Transparent),
- colors = SegmentedButtonDefaults.colors(
- activeContainerColor = SignalTheme.colors.colorTransparent3
- ),
- contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
- label = content
- )
-}
-
-@Composable
-private fun MediaCaptureMediaBar(
- selectedMedia: List,
- onEvent: (MediaCaptureScreenEvents) -> Unit,
- modifier: Modifier = Modifier
-) {
- val cameraDisplay = CameraDisplay.rememberCameraDisplay(isLandscape = false)
-
- Box(modifier = modifier.fillMaxWidth()) {
- Row(
- horizontalArrangement = spacedBy(12.dp),
- verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
- .align(Alignment.Center)
- .padding(bottom = cameraDisplay.getToggleBottomMargin().dp)
- .height(44.dp)
- .background(color = colorResource(R.color.MediaSend_controls_color), shape = RoundedCornerShape(50))
- .padding(horizontal = 6.dp, vertical = 6.dp)
- .padding(end = 10.dp)
- ) {
- if (LocalInspectionMode.current) {
- Box(
- modifier = Modifier
- .size(32.dp)
- .background(color = Color.Red, shape = CircleShape)
- )
- } else {
- GlideImage(
- model = DecryptableUri(selectedMedia.last().uri),
- modifier = Modifier
- .size(32.dp)
- .clip(CircleShape)
- )
- }
-
- Text(
- text = pluralStringResource(R.plurals.MediaCaptureScreen_n_items, selectedMedia.size, selectedMedia.size),
- color = SignalTheme.colors.colorOnCustom,
- modifier = Modifier.testTag(TestTags.MEDIA_CAPTURE_MEDIA_COUNT)
- )
- }
-
- NextButton(
- onEvent = onEvent,
- modifier = Modifier.align(Alignment.BottomEnd)
+ .align(Alignment.BottomCenter)
+ .navigationBarsPadding()
)
}
}
+/**
+ * The mode bar and the next button, which share the bottom of the screen: the bar runs the full width with its selection
+ * centered, and the button floats over its end.
+ *
+ * The row keeps a fixed height so neither one moving in or out shifts the other.
+ */
@Composable
-private fun NextButton(
+private fun MediaCaptureBottomControls(
+ state: MediaCaptureState,
onEvent: (MediaCaptureScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val cameraDisplay = CameraDisplay.rememberCameraDisplay(isLandscape = false)
+ val endMargin = cameraDisplay.getNextPaddingEnd().dp
- IconButton(
- onClick = { onEvent(MediaCaptureScreenEvents.NextClicked) },
+ // The bar only has to get out of the way while the button is over it, and follows it in and out so the fade does not
+ // snap on around a button that is still arriving.
+ val fadeFraction by animateFloatAsState(
+ targetValue = if (state.canDisplayNextButton) 1f else 0f,
+ animationSpec = tween(durationMillis = CONTROL_FADE_DURATION_MS),
+ label = "ModeBarFade"
+ )
+
+ // The bottom margin is applied outside the height rather than inside it, so the row is that tall in addition to
+ // sitting that far up rather than squeezing the bar and the button into what is left.
+ Box(
modifier = modifier
- .padding(bottom = cameraDisplay.getNextPaddingBottom().dp, end = cameraDisplay.getNextPaddingEnd().dp)
- .size(48.dp)
- .background(colorResource(org.signal.camera.R.color.CameraHud_control_background), shape = CircleShape)
- .testTag(TestTags.MEDIA_CAPTURE_NEXT_BUTTON)
+ .fillMaxWidth()
+ .padding(bottom = cameraDisplay.getToggleBottomMargin().dp)
+ .height(BOTTOM_CONTROLS_HEIGHT)
) {
- Icon(
- imageVector = SignalIcons.ArrowEnd.imageVector,
- contentDescription = null,
- tint = Color.White,
- modifier = Modifier.size(24.dp)
- )
+ AnimatedVisibility(
+ visible = state.canDisplayModeBar,
+ enter = fadeIn(animationSpec = tween(durationMillis = CONTROL_FADE_DURATION_MS)),
+ exit = fadeOut(animationSpec = tween(durationMillis = CONTROL_FADE_DURATION_MS)),
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = MODE_BAR_BOTTOM_INSET)
+ ) {
+ MediaCaptureModeBar(
+ availableCaptureModes = state.availableCaptureModes,
+ selectedCaptureMode = state.selectedCaptureMode,
+ onEvent = onEvent,
+ modifier = Modifier.endFadingEdge(
+ fadeWidth = MODE_BAR_FADE_WIDTH * fadeFraction,
+ inset = (endMargin + NEXT_BUTTON_CIRCLE_SIZE) * fadeFraction
+ )
+ )
+ }
+
+ AnimatedVisibility(
+ visible = state.canDisplayNextButton,
+ enter = fadeIn(animationSpec = tween(durationMillis = CONTROL_FADE_DURATION_MS)),
+ exit = fadeOut(animationSpec = tween(durationMillis = CONTROL_FADE_DURATION_MS)),
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = endMargin)
+ ) {
+ NextButton(
+ selectedMediaCount = state.selectedMedia.size,
+ onClick = { onEvent(MediaCaptureScreenEvents.NextClicked) },
+ recipientChatColor = chatColorFor(state.recipientId)
+ )
+ }
}
}
@@ -271,6 +177,10 @@ private fun MediaCaptureScreenPreview() {
}
}
+/**
+ * A flow already carrying a capture: the text story is withdrawn from the bar, the next button floats over its end, and
+ * the bar has faded out behind the button rather than running under it.
+ */
@NightPreview
@Composable
private fun MediaCaptureScreenWithSelectedMediaPreview() {
@@ -285,6 +195,21 @@ private fun MediaCaptureScreenWithSelectedMediaPreview() {
}
}
+/** A count wide enough to push the badge past the circle it straddles, which widens the button. */
+@NightPreview
+@Composable
+private fun MediaCaptureScreenWithManySelectedMediaPreview() {
+ val selectedMedia = rememberPreviewMedia(12)
+
+ Previews.Preview {
+ MediaCaptureScreen(
+ state = rememberPreviewCaptureState().copy(selectedMedia = selectedMedia),
+ onEvent = {},
+ textStoryEditorSlot = {}
+ )
+ }
+}
+
@Composable
private fun rememberPreviewCaptureState(): MediaCaptureState = remember {
MediaCaptureState(
@@ -294,42 +219,3 @@ private fun rememberPreviewCaptureState(): MediaCaptureState = remember {
mediaConstraints = PreviewMediaConstraints
)
}
-
-@NightPreview
-@Composable
-fun MediaCaptureToggleBarPreview() {
- var selectedCaptureScreen: MediaSendRoute.Capture by remember { mutableStateOf(MediaSendRoute.Capture.Camera) }
-
- Previews.Preview {
- MediaCaptureToggleBar(
- selectedCaptureScreen = selectedCaptureScreen,
- onEvent = {
- when (it) {
- MediaCaptureScreenEvents.ShowCamera -> selectedCaptureScreen = MediaSendRoute.Capture.Camera
- MediaCaptureScreenEvents.ShowTextStory -> selectedCaptureScreen = MediaSendRoute.Capture.TextStory
- else -> Unit
- }
- }
- )
- }
-}
-
-@NightPreview
-@Composable
-fun MediaCaptureMediaBarPreview() {
- var selectedCaptureScreen: MediaSendRoute.Capture by remember { mutableStateOf(MediaSendRoute.Capture.Camera) }
- val selectedMedia = rememberPreviewMedia(1)
-
- Previews.Preview {
- MediaCaptureMediaBar(
- selectedMedia = selectedMedia,
- onEvent = {
- when (it) {
- MediaCaptureScreenEvents.ShowCamera -> selectedCaptureScreen = MediaSendRoute.Capture.Camera
- MediaCaptureScreenEvents.ShowTextStory -> selectedCaptureScreen = MediaSendRoute.Capture.TextStory
- else -> Unit
- }
- }
- )
- }
-}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvents.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvents.kt
index 4918cf63af..ecfd52d36e 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvents.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvents.kt
@@ -8,7 +8,7 @@ package org.signal.mediasend.screens.capture
import org.signal.mediasend.MediaSendFlowState
import org.signal.mediasend.MediaSendRoute
-sealed interface MediaCaptureScreenEvents {
+internal sealed interface MediaCaptureScreenEvents {
/** The parent flow's state changed and needs to be merged into this screen's state. */
data class ParentStateChanged(val parentState: MediaSendFlowState) : MediaCaptureScreenEvents {
@@ -20,8 +20,9 @@ sealed interface MediaCaptureScreenEvents {
/** Navigation moved between the camera and the text story editor. */
data class SelectedCaptureScreenChanged(val selectedCaptureScreen: MediaSendRoute.Capture) : MediaCaptureScreenEvents
- data object ShowCamera : MediaCaptureScreenEvents
- data object ShowTextStory : MediaCaptureScreenEvents
+ /** A capture mode was picked from the bottom bar. */
+ data class CaptureModeSelected(val mode: MediaCaptureMode) : MediaCaptureScreenEvents
+
data object NextClicked : MediaCaptureScreenEvents
/** Something the camera reported. What becomes of it is the flow's to decide rather than this screen's. */
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureState.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureState.kt
index 163f14a7b6..498d92390c 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureState.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureState.kt
@@ -7,13 +7,15 @@ package org.signal.mediasend.screens.capture
import org.signal.core.models.media.Media
import org.signal.mediasend.MediaConstraints
+import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendFlowActivityContract
import org.signal.mediasend.MediaSendRoute
import kotlin.time.Duration
/**
- * What the capture screen renders. Which capture screen is showing is navigation and the selection is the flow's, so
- * both arrive from the parent; everything else is fixed for the life of the flow and read once at construction.
+ * What the capture screen renders. Which capture screen is showing is navigation's and the selection is the flow's, so
+ * both arrive from the parent; the camera reports its own state as it changes, and everything else is fixed for the life
+ * of the flow and read once at construction.
*/
internal data class MediaCaptureState(
val selectedCaptureScreen: MediaSendRoute.Capture = MediaSendRoute.Capture.Camera,
@@ -22,29 +24,63 @@ internal data class MediaCaptureState(
val isStory: Boolean = false,
val storiesEnabled: Boolean = false,
val mode: MediaSendFlowActivityContract.Mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
+ /** Who this is headed to, when that is already settled. Only the chrome's tint reads it. */
+ val recipientId: MediaRecipientId? = null,
/** Null leaves recording on the most conservative limits this device supports. */
val mediaConstraints: MediaConstraints? = null,
- val storyMaxVideoDuration: Duration = Duration.ZERO
+ val storyMaxVideoDuration: Duration = Duration.ZERO,
+ /** Only ever [MediaCaptureMode.PHOTO] or [MediaCaptureMode.VIDEO]; whether the text story is showing is navigation's. */
+ val selectedCameraMode: MediaCaptureMode = MediaCaptureMode.PHOTO,
+ val isVideoCaptureSupported: Boolean = true,
+ /** As the camera reports it, so only ever true of the camera screen. */
+ val isRecording: Boolean = false
) {
/**
- * Whether the camera's own chrome is joined by the flow's. Only a camera-first flow headed somewhere a text story can
- * go has anything to add.
+ * The modes this flow offers, in the order the bottom bar shows them, which leaves [MediaCaptureMode.PHOTO] in the
+ * middle of a full flow.
*/
- val canDisplayBottomBar: Boolean
- get() {
- val isSingleStory = mode == MediaSendFlowActivityContract.Mode.SingleRecipient && isStory
- return isCameraFirst && storiesEnabled && (mode == MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection || isSingleStory)
+ val availableCaptureModes: List
+ get() = buildList {
+ if (isVideoCaptureSupported) {
+ add(MediaCaptureMode.VIDEO)
+ }
+
+ add(MediaCaptureMode.PHOTO)
+
+ if (canOfferTextStory) {
+ add(MediaCaptureMode.TEXT_STORY)
+ }
}
- /** The toggle holds the spot the media bar takes over once something has been captured. */
- val canDisplayToggleSwitch: Boolean
- get() = selectedMedia.isEmpty()
+ /**
+ * Whether the bar for switching between [availableCaptureModes] is up. A flow left with a single mode has nothing to
+ * switch between, and a running recording has the screen to itself.
+ */
+ val canDisplayModeBar: Boolean
+ get() = availableCaptureModes.size > 1 && !isRecording
- val canDisplayMediaBar: Boolean
- get() = selectedMedia.isNotEmpty()
+ /** Whether the button for moving on to the editor is up. */
+ val canDisplayNextButton: Boolean
+ get() = selectedMedia.isNotEmpty() && !isRecording
- /** The cap a story puts on a recording's length, or zero to leave the device's own cap in place. */
+ /** Which of [availableCaptureModes] is showing, taking navigation's word for it over the camera's own selection. */
+ val selectedCaptureMode: MediaCaptureMode
+ get() = if (selectedCaptureScreen == MediaSendRoute.Capture.TextStory) MediaCaptureMode.TEXT_STORY else selectedCameraMode
+
+ /**
+ * Only a camera-first flow headed somewhere a text story can go has one to offer, and only while the selection is
+ * empty: a text story is text alone, so the first capture or pick leaves no way to send one.
+ */
+ private val canOfferTextStory: Boolean
+ get() {
+ val isSingleStory = mode == MediaSendFlowActivityContract.Mode.SingleRecipient && isStory
+ val isHeadedSomewhereStoriesGo = mode == MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection || isSingleStory
+
+ return isCameraFirst && storiesEnabled && isHeadedSomewhereStoriesGo && selectedMedia.isEmpty()
+ }
+
+ /** The cap a story puts on a recording's length, or zero to leave the device's own in place. */
val maxVideoDurationSecondsOverride: Int
get() = if (isStory) storyMaxVideoDuration.inWholeSeconds.toInt() else 0
}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureViewModel.kt
index 52d724238a..b138f67551 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureViewModel.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureViewModel.kt
@@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
import org.signal.core.models.media.Media
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
+import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.MediaSendFlowEvent
import org.signal.mediasend.MediaSendFlowState
import org.signal.mediasend.MediaSendRoute
@@ -57,8 +58,10 @@ internal class MediaCaptureViewModel(
isStory = isStory,
storiesEnabled = storiesEnabled,
mode = mode,
+ recipientId = recipientId,
mediaConstraints = mediaConstraints,
- storyMaxVideoDuration = storyMaxVideoDuration
+ storyMaxVideoDuration = storyMaxVideoDuration,
+ isVideoCaptureSupported = MediaConstraints.isVideoTranscodeAvailable()
)
}
)
@@ -76,13 +79,26 @@ internal class MediaCaptureViewModel(
when (event) {
is MediaCaptureScreenEvents.ParentStateChanged -> _state.update { it.copy(selectedMedia = event.parentState.selectedMedia) }
is MediaCaptureScreenEvents.SelectedCaptureScreenChanged -> _state.update { it.copy(selectedCaptureScreen = event.selectedCaptureScreen) }
- MediaCaptureScreenEvents.ShowCamera -> parentEventEmitter(MediaSendFlowEvent.NavigateToCamera)
- MediaCaptureScreenEvents.ShowTextStory -> parentEventEmitter(MediaSendFlowEvent.NavigateToTextStory)
+ is MediaCaptureScreenEvents.CaptureModeSelected -> selectCaptureMode(event.mode)
MediaCaptureScreenEvents.NextClicked -> parentEventEmitter(MediaSendFlowEvent.NavigateToEdit)
is MediaCaptureScreenEvents.Camera -> processCameraEvent(event.event)
}
}
+ /**
+ * Moving to the screen a mode needs is navigation's job, so that leaves as a request. The camera's own two modes share
+ * a screen and are recorded here instead.
+ */
+ private fun selectCaptureMode(mode: MediaCaptureMode) {
+ when (mode.captureScreen) {
+ MediaSendRoute.Capture.TextStory -> parentEventEmitter(MediaSendFlowEvent.NavigateToTextStory)
+ else -> {
+ _state.update { it.copy(selectedCameraMode = mode) }
+ parentEventEmitter(MediaSendFlowEvent.NavigateToCamera)
+ }
+ }
+ }
+
private fun processCameraEvent(event: CameraXScreenEvents) {
when (event) {
is CameraXScreenEvents.ImageCaptured -> captureMedia(R.string.MediaSendViewModel__error_taking_photo) {
@@ -93,6 +109,7 @@ internal class MediaCaptureViewModel(
repository.writeCapturedVideo(event.fd)
}
+ is CameraXScreenEvents.RecordingStateChanged -> _state.update { it.copy(isRecording = event.isRecording) }
CameraXScreenEvents.VideoCaptureError -> showSnackbar(R.string.MediaSendViewModel__error_recording_video)
is CameraXScreenEvents.QrCodeFound -> parentEventEmitter(MediaSendFlowEvent.QrCodeScanned(event.data))
CameraXScreenEvents.GalleryClicked -> parentEventEmitter(MediaSendFlowEvent.NavigateToFolders)
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt
index 4548392528..ba337b72e3 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
@@ -89,7 +88,6 @@ import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.DropdownMenus
-import org.signal.core.ui.compose.LocalChatColorProvider
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
@@ -108,6 +106,8 @@ import org.signal.glide.compose.GlideImage
import org.signal.mediasend.R
import org.signal.mediasend.screens.MediaSendMetrics
import org.signal.mediasend.screens.edit.rememberPreviewMedia
+import org.signal.mediasend.screens.shared.NextButton
+import org.signal.mediasend.screens.shared.chatColorFor
import org.signal.mediasend.test.TestTags
import org.signal.mediasend.util.formatAsClock
import kotlin.time.Duration.Companion.milliseconds
@@ -133,7 +133,7 @@ internal fun MediaSelectScreen(
val gridConfiguration = rememberGridConfiguration(state is MediaSelectState.Folders && !showPlaceholders)
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
- val recipientChatColor: Color? = state.recipientId?.let { LocalChatColorProvider.current(it.id).value }
+ val recipientChatColor: Color? = chatColorFor(state.recipientId)
val gridState = rememberLazyGridState()
val dragToSelectState = rememberDragToSelectMediaState(state, onEvent, gridState)
@@ -268,11 +268,10 @@ internal fun MediaSelectScreen(
)
NextButton(
- mediaSelectionCount = state.selectedMedia.size,
+ selectedMediaCount = state.selectedMedia.size,
+ onClick = { onEvent(MediaSelectScreenEvents.NavigateToEdit) },
recipientChatColor = recipientChatColor
- ) {
- onEvent(MediaSelectScreenEvents.NavigateToEdit)
- }
+ )
}
}
}
@@ -684,31 +683,6 @@ private fun MediaTileVideoOverlay(
}
}
-@Composable
-private fun NextButton(mediaSelectionCount: Int, recipientChatColor: Color? = null, onClick: () -> Unit) {
- Buttons.MediumTonal(
- onClick = onClick,
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp)
- ) {
- Box(
- modifier = Modifier
- .background(color = recipientChatColor ?: MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(percent = 50))
- .ensureWidthIsAtLeastHeight()
- ) {
- Text(
- text = "$mediaSelectionCount",
- color = if (recipientChatColor != null) SignalTheme.colors.colorOnCustom else MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier
- )
- }
-
- Icon(
- imageVector = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_chevron_right_24),
- contentDescription = stringResource(R.string.MediaSelectScreen__next)
- )
- }
-}
-
/**
* The rail of currently selected media. Items can be long pressed and dragged to change the order they'll be sent in.
*/
@@ -944,29 +918,6 @@ private fun MediaTileSelectedChatColorPreview() {
}
}
-@DayNightPreviews
-@Composable
-private fun NextButtonPreview() {
- Previews.Preview {
- NextButton(
- mediaSelectionCount = 3,
- onClick = {}
- )
- }
-}
-
-@DayNightPreviews
-@Composable
-private fun NextButtonChatColorPreview() {
- Previews.Preview {
- NextButton(
- mediaSelectionCount = 3,
- recipientChatColor = Color(0xFF3B7845),
- onClick = {}
- )
- }
-}
-
@Composable
private fun rememberPreviewMediaFolders(count: Int): List {
return remember(count) {
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/shared/NextButton.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/shared/NextButton.kt
new file mode 100644
index 0000000000..f455826469
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/shared/NextButton.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.screens.shared
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.compose.NightPreview
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.SignalIcons
+import org.signal.core.ui.compose.theme.SignalTheme
+import org.signal.mediasend.R
+import org.signal.mediasend.test.TestTags
+
+/** The circle itself, which anything sharing its row lines up against. */
+internal val NEXT_BUTTON_CIRCLE_SIZE = 40.dp
+
+/** The circle is smaller than a finger, so the touch target is grown around it rather than the circle drawn bigger. */
+internal val NEXT_BUTTON_TOUCH_TARGET = 48.dp
+
+private val NEXT_BUTTON_ICON_SIZE = 24.dp
+private val NEXT_COUNT_HEIGHT = 18.dp
+private val NEXT_COUNT_HORIZONTAL_PADDING = 6.dp
+
+/**
+ * How much of the count clears the touch target once half of it is over the circle's top edge. Padding the button by it
+ * puts the count's middle exactly on that edge.
+ */
+private val NEXT_COUNT_OVERHANG = NEXT_COUNT_HEIGHT / 2 - (NEXT_BUTTON_TOUCH_TARGET - NEXT_BUTTON_CIRCLE_SIZE) / 2
+
+/** The whole button, count and all, which is the room a row has to leave for it. */
+internal val NEXT_BUTTON_HEIGHT = NEXT_BUTTON_TOUCH_TARGET + NEXT_COUNT_OVERHANG
+
+/**
+ * Moves the flow on to the editor and says how much is waiting there. The count straddles the circle's top edge, which
+ * keeps it clear of whatever the button is floating over.
+ *
+ * The fill is the camera's control background wherever this is used, so the button looks the same over a viewfinder as
+ * over the picker's bottom bar. Only the count takes a color from the send itself.
+ *
+ * @param selectedMediaCount How much the editor has waiting for it
+ * @param recipientChatColor The color of the one conversation this is headed to, or null for a send with no single
+ * destination, which leaves the count on the theme's own color.
+ */
+@Composable
+internal fun NextButton(
+ selectedMediaCount: Int,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ recipientChatColor: Color? = null
+) {
+ Box(modifier = modifier.widthIn(min = NEXT_BUTTON_TOUCH_TARGET)) {
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(top = NEXT_COUNT_OVERHANG)
+ .size(NEXT_BUTTON_TOUCH_TARGET)
+ .testTag(TestTags.MEDIA_SEND_NEXT_BUTTON)
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(NEXT_BUTTON_CIRCLE_SIZE)
+ .background(colorResource(org.signal.camera.R.color.CameraHud_control_background), shape = CircleShape)
+ ) {
+ Icon(
+ imageVector = SignalIcons.ArrowEnd.imageVector,
+ contentDescription = stringResource(R.string.MediaSelectScreen__next),
+ tint = Color.White,
+ modifier = Modifier.size(NEXT_BUTTON_ICON_SIZE)
+ )
+ }
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .heightIn(min = NEXT_COUNT_HEIGHT)
+ .widthIn(min = NEXT_COUNT_HEIGHT)
+ .background(color = recipientChatColor ?: MaterialTheme.colorScheme.primaryContainer, shape = CircleShape)
+ .padding(horizontal = NEXT_COUNT_HORIZONTAL_PADDING)
+ ) {
+ Text(
+ text = selectedMediaCount.toString(),
+ color = if (recipientChatColor != null) SignalTheme.colors.colorOnCustom else MaterialTheme.colorScheme.onPrimaryContainer,
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ modifier = Modifier.testTag(TestTags.MEDIA_SEND_MEDIA_COUNT)
+ )
+ }
+ }
+}
+
+@NightPreview
+@Composable
+private fun NextButtonPreview() {
+ Previews.Preview {
+ NextButton(selectedMediaCount = 1, onClick = {})
+ }
+}
+
+/** A count wide enough to push past the circle it straddles, which widens the button. */
+@NightPreview
+@Composable
+private fun NextButtonWideCountPreview() {
+ Previews.Preview {
+ NextButton(selectedMediaCount = 12, onClick = {})
+ }
+}
+
+/** A send headed to one conversation, so the count carries that conversation's color. */
+@NightPreview
+@Composable
+private fun NextButtonChatColorPreview() {
+ Previews.Preview {
+ NextButton(selectedMediaCount = 3, onClick = {}, recipientChatColor = Color(0xFF3B7845))
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/shared/RecipientChatColor.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/shared/RecipientChatColor.kt
new file mode 100644
index 0000000000..b16267d11c
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/shared/RecipientChatColor.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.screens.shared
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import org.signal.core.ui.compose.LocalChatColorProvider
+import org.signal.mediasend.MediaRecipientId
+
+/**
+ * The chat color of the one recipient this send is headed to, which the flow's chrome tints itself with so a send looks
+ * like the conversation it is going to.
+ *
+ * Null when there is no single conversation to take a color from: a story, or a flow that has yet to pick a destination.
+ * Callers fall back to the theme rather than to a color of their own.
+ */
+@Composable
+internal fun chatColorFor(recipientId: MediaRecipientId?): Color? {
+ return recipientId?.let { LocalChatColorProvider.current(it.id).value }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt
index e97ed38c8b..d47f77edc2 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt
@@ -26,10 +26,14 @@ object TestTags {
// Media Capture Screen
const val MEDIA_CAPTURE_SCREEN = "media_capture_screen"
- const val MEDIA_CAPTURE_CAMERA_TOGGLE = "media_capture_camera_toggle"
+ const val MEDIA_CAPTURE_MODE_BAR = "media_capture_mode_bar"
+ const val MEDIA_CAPTURE_PHOTO_TOGGLE = "media_capture_photo_toggle"
+ const val MEDIA_CAPTURE_VIDEO_TOGGLE = "media_capture_video_toggle"
const val MEDIA_CAPTURE_TEXT_STORY_TOGGLE = "media_capture_text_story_toggle"
- const val MEDIA_CAPTURE_MEDIA_COUNT = "media_capture_media_count"
- const val MEDIA_CAPTURE_NEXT_BUTTON = "media_capture_next_button"
+
+ // Shared. The next button is the same control on the capture and select screens, so it has one tag.
+ const val MEDIA_SEND_MEDIA_COUNT = "media_send_media_count"
+ const val MEDIA_SEND_NEXT_BUTTON = "media_send_next_button"
// Media Select Screen
const val MEDIA_SELECT_GRID = "media_select_grid"
diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml
index abb0c90f6e..ebea0e88f7 100644
--- a/feature/media-send/src/main/res/values/strings.xml
+++ b/feature/media-send/src/main/res/values/strings.xml
@@ -85,15 +85,12 @@
Signal needs access to show your photos and videos.
-
- Camera
+
+ Photo
+
+ Video
Text
-
-
- - %1$d item
- - %1$d items
-
Add link
diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt
index d9c08e8ef3..26369881fa 100644
--- a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt
+++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt
@@ -9,16 +9,23 @@ import android.app.Application
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.swipeLeft
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpRect
import androidx.core.net.toUri
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.containsExactly
+import assertk.assertions.isCloseTo
import assertk.assertions.isEmpty
import org.junit.Rule
import org.junit.Test
@@ -36,8 +43,8 @@ import org.signal.mediasend.test.TestTags
/**
* Covers the chrome the flow adds over a capture screen: which bar is offered, to which flows, and what it raises.
*
- * The bars are rendered on the text story route, so that they are what is under test rather than the camera behind
- * them. Which of the two the route actually puts up is covered on its own.
+ * The bars are rendered on the text story route so they are what is under test rather than the camera behind them.
+ * Which of the two the route puts up is covered separately.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class, qualifiers = "w400dp-h800dp")
@@ -55,61 +62,100 @@ class MediaCaptureScreenTest {
private val events = mutableListOf()
@Test
- fun `Given a camera-first flow with nothing captured, when displayed, then the toggle is offered`() {
+ fun `Given a flow that offers every mode, when displayed, then each one is on the bar`() {
setContent(cameraFirstState())
- composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_PHOTO_TOGGLE).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_VIDEO_TOGGLE).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).assertIsDisplayed()
}
@Test
- fun `Given a flow headed straight to a chat, when displayed, then no bar is offered`() {
+ fun `Given a flow that offers every mode, when displayed, then the selected one is centered under the highlight`() {
+ setContent(cameraFirstState())
+
+ val bar = composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MODE_BAR).getUnclippedBoundsInRoot()
+ val selected = composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).getUnclippedBoundsInRoot()
+
+ assertThat(selected.centerX.value).isCloseTo(bar.centerX.value, 1f)
+ }
+
+ @Test
+ fun `when the bar is swiped, then nothing is asked for until the swipe is released`() {
+ setContent(cameraFirstState())
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MODE_BAR).performTouchInput {
+ down(centerLeft)
+ moveBy(Offset(x = width.toFloat(), y = 0f))
+ }
+ composeTestRule.waitForIdle()
+
+ assertThat(events).isEmpty()
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MODE_BAR).performTouchInput { up() }
+ composeTestRule.waitForIdle()
+
+ assertThat(events).containsExactly(MediaCaptureScreenEvents.CaptureModeSelected(MediaCaptureMode.VIDEO))
+ }
+
+ @Test
+ fun `when the bar is swiped and released, then the mode under the center is what is asked for`() {
+ setContent(cameraFirstState().copy(selectedCaptureScreen = MediaSendRoute.Capture.Camera, selectedCameraMode = MediaCaptureMode.PHOTO))
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MODE_BAR).performTouchInput {
+ swipeLeft(startX = centerRight.x, endX = centerLeft.x)
+ }
+ composeTestRule.waitForIdle()
+
+ assertThat(captureModeSelections).containsExactly(MediaCaptureMode.TEXT_STORY)
+ }
+
+ @Test
+ fun `Given a flow headed straight to a chat, when displayed, then the text story is not on the bar`() {
setContent(cameraFirstState().copy(mode = MediaSendFlowActivityContract.Mode.SingleRecipient, isStory = false))
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_SCREEN).assertIsDisplayed()
- composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertDoesNotExist()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).assertDoesNotExist()
+ }
+
+ /** A single mode has nothing to switch between, so no bar is put up. */
+ @Test
+ fun `Given a flow with only one mode, when displayed, then no bar is offered`() {
+ setContent(cameraFirstState().copy(isCameraFirst = false, isVideoCaptureSupported = false))
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_PHOTO_TOGGLE).assertDoesNotExist()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).assertDoesNotExist()
}
@Test
- fun `Given a flow that is not camera-first, when displayed, then no bar is offered`() {
- setContent(cameraFirstState().copy(isCameraFirst = false))
+ fun `Given a device that cannot record, when displayed, then video is not on the bar`() {
+ setContent(cameraFirstState().copy(isVideoCaptureSupported = false))
- composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertDoesNotExist()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_PHOTO_TOGGLE).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_VIDEO_TOGGLE).assertDoesNotExist()
}
@Test
- fun `when the camera is picked from the toggle, then it is asked for`() {
+ fun `when a mode is picked from the bar, then it is asked for`() {
setContent(cameraFirstState())
- composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).performClick()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_VIDEO_TOGGLE).performClick()
- assertThat(events).containsExactly(MediaCaptureScreenEvents.ShowCamera)
+ assertThat(events).containsExactly(MediaCaptureScreenEvents.CaptureModeSelected(MediaCaptureMode.VIDEO))
}
@Test
- fun `when the text story is picked from the toggle, then it is asked for`() {
- setContent(cameraFirstState())
+ fun `Given a recording is running, when displayed, then no bar is offered`() {
+ setContent(cameraFirstState().copy(selectedCaptureScreen = MediaSendRoute.Capture.Camera, isRecording = true))
- composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).performClick()
-
- assertThat(events).containsExactly(MediaCaptureScreenEvents.ShowTextStory)
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MODE_BAR).assertDoesNotExist()
}
@Test
- fun `Given something has been captured, when displayed, then the media bar replaces the toggle`() {
- setContent(cameraFirstState().copy(selectedMedia = listOf(MEDIA)))
+ fun `Given a recording that has finished, when displayed, then the bar is back`() {
+ setContent(cameraFirstState().copy(selectedCaptureScreen = MediaSendRoute.Capture.Camera, isRecording = false))
- composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MEDIA_COUNT).assertTextEquals("1 item")
- composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertDoesNotExist()
- }
-
- @Test
- fun `Given something has been captured, when next is clicked, then the flow is asked to move on`() {
- setContent(cameraFirstState().copy(selectedMedia = listOf(MEDIA)))
-
- composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_NEXT_BUTTON).performClick()
-
- assertThat(events).containsExactly(MediaCaptureScreenEvents.NextClicked)
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MODE_BAR).assertIsDisplayed()
}
@Test
@@ -120,7 +166,7 @@ class MediaCaptureScreenTest {
assertThat(events).isEmpty()
}
- /** The camera is the fallback for every capture route that is not the text story, including the flow's chrome key. */
+ /** The camera is the fallback for every capture route other than the text story. */
@Test
fun `Given the camera route, when displayed, then the text story editor is not what fills the screen`() {
setContent(cameraFirstState().copy(selectedCaptureScreen = MediaSendRoute.Capture.Camera))
@@ -133,10 +179,71 @@ class MediaCaptureScreenTest {
fun `Given the camera route, when displayed, then the flow's chrome sits over it`() {
setContent(cameraFirstState().copy(selectedCaptureScreen = MediaSendRoute.Capture.Camera))
- composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_PHOTO_TOGGLE).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).assertIsDisplayed()
}
+ /** A text story is text alone, so a flow already carrying a capture has no way to send one. */
+ @Test
+ fun `Given something has been captured, when displayed, then the text story is no longer on the bar`() {
+ setContent(withSelectedMedia())
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).assertDoesNotExist()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_PHOTO_TOGGLE).assertIsDisplayed()
+ }
+
+ //region The next button over the bar's end
+
+ @Test
+ fun `Given nothing has been captured, when displayed, then there is no next button`() {
+ setContent(cameraFirstState().copy(selectedCaptureScreen = MediaSendRoute.Capture.Camera))
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_NEXT_BUTTON).assertDoesNotExist()
+ }
+
+ @Test
+ fun `Given something has been captured, when displayed, then the next button says how much is waiting`() {
+ setContent(withSelectedMedia(count = 3))
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_NEXT_BUTTON).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_MEDIA_COUNT).assertTextEquals("3")
+ }
+
+ @Test
+ fun `Given something has been captured, when next is clicked, then the flow is asked to move on`() {
+ setContent(withSelectedMedia())
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_NEXT_BUTTON).performClick()
+
+ assertThat(chromeRequests).containsExactly(MediaCaptureScreenEvents.NextClicked)
+ }
+
+ @Test
+ fun `Given a recording is running, when displayed, then the next button is taken away with the bar`() {
+ setContent(withSelectedMedia().copy(isRecording = true))
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_NEXT_BUTTON).assertDoesNotExist()
+ }
+
+ //endregion
+
+ /** The camera reports into the same stream, so what the bar asked for has to be filtered out of it. */
+ private val captureModeSelections: List
+ get() = events.filterIsInstance().map { it.mode }
+
+ /** Everything the chrome asked for: the same stream with the camera's own reports removed. */
+ private val chromeRequests: List
+ get() = events.filterNot { it is MediaCaptureScreenEvents.Camera }
+
+ private val DpRect.centerX: Dp
+ get() = (left + right) / 2
+
+ /** A camera-first flow that has already captured something, which puts the next button up. */
+ private fun withSelectedMedia(count: Int = 1) = cameraFirstState().copy(
+ selectedCaptureScreen = MediaSendRoute.Capture.Camera,
+ selectedMedia = List(count) { MEDIA }
+ )
+
private fun cameraFirstState() = MediaCaptureState(
selectedCaptureScreen = MediaSendRoute.Capture.TextStory,
isCameraFirst = true,
diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt
index 9d4bc4095c..4ee4d74c82 100644
--- a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt
+++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt
@@ -10,10 +10,13 @@ import androidx.annotation.StringRes
import androidx.core.net.toUri
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
+import assertk.assertions.contains
import assertk.assertions.containsExactly
+import assertk.assertions.doesNotContain
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
+import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.coVerify
@@ -36,6 +39,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.models.media.Media
import org.signal.core.util.SeekableFileDescriptor
+import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendDependenciesRule
import org.signal.mediasend.MediaSendFlowActivityContract
import org.signal.mediasend.MediaSendFlowEvent
@@ -75,42 +79,95 @@ class MediaCaptureViewModelTest {
//region Chrome the flow's configuration decides
@Test
- fun `Given a camera-first flow that has yet to pick a destination, when created, then the bottom bar can display`() = runTest {
+ fun `Given a camera-first flow that has yet to pick a destination, when created, then the text story is on offer`() = runTest {
val viewModel = createViewModel(cameraFirstStoryCapableState())
- assertThat(viewModel.state.value.canDisplayBottomBar).isTrue()
+ assertThat(viewModel.state.value.availableCaptureModes).contains(MediaCaptureMode.TEXT_STORY)
}
@Test
- fun `Given a camera-first flow aimed at one recipient's story, when created, then the bottom bar can display`() = runTest {
+ fun `Given a camera-first flow aimed at one recipient's story, when created, then the text story is on offer`() = runTest {
val viewModel = createViewModel(
cameraFirstStoryCapableState().copy(mode = MediaSendFlowActivityContract.Mode.SingleRecipient, isStory = true)
)
- assertThat(viewModel.state.value.canDisplayBottomBar).isTrue()
+ assertThat(viewModel.state.value.availableCaptureModes).contains(MediaCaptureMode.TEXT_STORY)
}
@Test
- fun `Given a camera-first flow headed straight to a chat, when created, then the bottom bar stays hidden`() = runTest {
+ fun `Given a camera-first flow headed straight to a chat, when created, then the text story is withheld`() = runTest {
val viewModel = createViewModel(
cameraFirstStoryCapableState().copy(mode = MediaSendFlowActivityContract.Mode.SingleRecipient, isStory = false)
)
- assertThat(viewModel.state.value.canDisplayBottomBar).isFalse()
+ assertThat(viewModel.state.value.availableCaptureModes).doesNotContain(MediaCaptureMode.TEXT_STORY)
}
@Test
- fun `Given stories are unavailable, when created, then the bottom bar stays hidden`() = runTest {
+ fun `Given stories are unavailable, when created, then the text story is withheld`() = runTest {
val viewModel = createViewModel(cameraFirstStoryCapableState().copy(storiesEnabled = false))
- assertThat(viewModel.state.value.canDisplayBottomBar).isFalse()
+ assertThat(viewModel.state.value.availableCaptureModes).doesNotContain(MediaCaptureMode.TEXT_STORY)
}
@Test
- fun `Given the camera was not what opened the flow, when created, then the bottom bar stays hidden`() = runTest {
+ fun `Given the camera was not what opened the flow, when created, then the text story is withheld`() = runTest {
val viewModel = createViewModel(cameraFirstStoryCapableState().copy(isCameraFirst = false))
- assertThat(viewModel.state.value.canDisplayBottomBar).isFalse()
+ assertThat(viewModel.state.value.availableCaptureModes).doesNotContain(MediaCaptureMode.TEXT_STORY)
+ }
+
+ /**
+ * A text story is text alone, so a flow already carrying a capture has no way to send one. The offer has to be
+ * withdrawn as the selection arrives rather than only at construction, since the capture happens on this screen.
+ */
+ @Test
+ fun `Given a camera-first flow, when a capture joins the selection, then the text story is withdrawn`() = runTest {
+ val parentState = MutableStateFlow(cameraFirstStoryCapableState())
+ val viewModel = createViewModel(parentState)
+ assertThat(viewModel.state.value.availableCaptureModes).contains(MediaCaptureMode.TEXT_STORY)
+
+ parentState.value = parentState.value.copy(selectedMedia = listOf(MEDIA))
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value.availableCaptureModes).doesNotContain(MediaCaptureMode.TEXT_STORY)
+ }
+
+ @Test
+ fun `Given a camera-first flow that already has a selection, when created, then the text story is withheld`() = runTest {
+ val viewModel = createViewModel(cameraFirstStoryCapableState().copy(selectedMedia = listOf(MEDIA)))
+
+ assertThat(viewModel.state.value.availableCaptureModes).doesNotContain(MediaCaptureMode.TEXT_STORY)
+ }
+
+ /** Emptying the selection puts the flow back where it started, so the text story is on offer again. */
+ @Test
+ fun `Given a selection that is cleared, when it empties, then the text story is on offer again`() = runTest {
+ val parentState = MutableStateFlow(cameraFirstStoryCapableState().copy(selectedMedia = listOf(MEDIA)))
+ val viewModel = createViewModel(parentState)
+
+ parentState.value = parentState.value.copy(selectedMedia = emptyList())
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value.availableCaptureModes).contains(MediaCaptureMode.TEXT_STORY)
+ }
+
+ /** Recording needs the transcoder, so a device without it has no video mode to offer. */
+ @Test
+ fun `Given a device that can record, when created, then every mode the flow allows is on offer`() = runTest {
+ val viewModel = createViewModel(cameraFirstStoryCapableState())
+
+ assertThat(viewModel.state.value.availableCaptureModes)
+ .containsExactly(MediaCaptureMode.VIDEO, MediaCaptureMode.PHOTO, MediaCaptureMode.TEXT_STORY)
+ }
+
+ @Test
+ @Config(sdk = [25])
+ fun `Given a device that cannot record, when created, then video is withheld`() = runTest {
+ val viewModel = createViewModel(cameraFirstStoryCapableState())
+
+ assertThat(viewModel.state.value.availableCaptureModes)
+ .containsExactly(MediaCaptureMode.PHOTO, MediaCaptureMode.TEXT_STORY)
}
@Test
@@ -154,7 +211,7 @@ class MediaCaptureViewModelTest {
/**
* Which capture screen is showing is not the flow's to report, and a capture landing in the selection is exactly when
- * the flow reports something while the text story editor is open.
+ * the flow does report something while the text story editor is open.
*/
@Test
fun `Given the text story editor is open, when the flow's selection changes, then it stays open`() = runTest {
@@ -170,6 +227,118 @@ class MediaCaptureViewModelTest {
assertThat(viewModel.state.value.selectedMedia).containsExactly(MEDIA)
}
+ @Test
+ fun `when a camera mode is picked, then it becomes the selected mode`() = runTest {
+ val viewModel = createViewModel()
+
+ viewModel.onEvent(MediaCaptureScreenEvents.CaptureModeSelected(MediaCaptureMode.VIDEO))
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value.selectedCaptureMode).isEqualTo(MediaCaptureMode.VIDEO)
+ }
+
+ /**
+ * The text story is a screen of its own rather than a mode of the camera, so navigation reports it as showing and the
+ * camera mode survives the trip.
+ */
+ @Test
+ fun `Given a camera mode was picked, when the text story opens and closes, then the camera mode is still selected`() = runTest {
+ val viewModel = createViewModel()
+ viewModel.onEvent(MediaCaptureScreenEvents.CaptureModeSelected(MediaCaptureMode.VIDEO))
+
+ viewModel.onEvent(MediaCaptureScreenEvents.SelectedCaptureScreenChanged(MediaSendRoute.Capture.TextStory))
+ advanceUntilIdle()
+ assertThat(viewModel.state.value.selectedCaptureMode).isEqualTo(MediaCaptureMode.TEXT_STORY)
+
+ viewModel.onEvent(MediaCaptureScreenEvents.SelectedCaptureScreenChanged(MediaSendRoute.Capture.Camera))
+ advanceUntilIdle()
+ assertThat(viewModel.state.value.selectedCaptureMode).isEqualTo(MediaCaptureMode.VIDEO)
+ }
+
+ /** A running recording has the screen to itself, so there is no point offering the mode bar. */
+ @Test
+ fun `Given a recording is running, when reported, then the mode bar is withheld`() = runTest {
+ val viewModel = createViewModel(cameraFirstStoryCapableState())
+
+ viewModel.onEvent(camera(CameraXScreenEvents.RecordingStateChanged(isRecording = true)))
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value.isRecording).isTrue()
+ assertThat(viewModel.state.value.canDisplayModeBar).isFalse()
+ }
+
+ @Test
+ fun `Given a recording that has finished, when reported, then the mode bar is back`() = runTest {
+ val viewModel = createViewModel(cameraFirstStoryCapableState())
+ viewModel.onEvent(camera(CameraXScreenEvents.RecordingStateChanged(isRecording = true)))
+
+ viewModel.onEvent(camera(CameraXScreenEvents.RecordingStateChanged(isRecording = false)))
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value.canDisplayModeBar).isTrue()
+ }
+
+ //region The button for moving on
+
+ @Test
+ fun `Given nothing has been captured, when created, then there is nothing to move on with`() = runTest {
+ val viewModel = createViewModel(cameraFirstStoryCapableState())
+
+ assertThat(viewModel.state.value.canDisplayNextButton).isFalse()
+ }
+
+ @Test
+ fun `Given a camera-first flow, when a capture joins the selection, then there is something to move on with`() = runTest {
+ val parentState = MutableStateFlow(cameraFirstStoryCapableState())
+ val viewModel = createViewModel(parentState)
+
+ parentState.value = parentState.value.copy(selectedMedia = listOf(MEDIA))
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value.canDisplayNextButton).isTrue()
+ }
+
+ @Test
+ fun `Given a selection, when a recording starts, then moving on is withheld until it finishes`() = runTest {
+ val viewModel = createViewModel(cameraFirstStoryCapableState().copy(selectedMedia = listOf(MEDIA)))
+
+ viewModel.onEvent(camera(CameraXScreenEvents.RecordingStateChanged(isRecording = true)))
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value.canDisplayNextButton).isFalse()
+
+ viewModel.onEvent(camera(CameraXScreenEvents.RecordingStateChanged(isRecording = false)))
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value.canDisplayNextButton).isTrue()
+ }
+
+ /** Only the chrome's tint reads it, but it still has to arrive for there to be anything to tint with. */
+ @Test
+ fun `Given a flow headed to one recipient, when created, then that recipient is carried through`() = runTest {
+ val viewModel = createViewModel(cameraFirstStoryCapableState().copy(recipientId = RECIPIENT_ID))
+
+ assertThat(viewModel.state.value.recipientId).isEqualTo(RECIPIENT_ID)
+ }
+
+ @Test
+ fun `Given a flow with no destination yet, when created, then there is no recipient to tint with`() = runTest {
+ val viewModel = createViewModel(cameraFirstStoryCapableState())
+
+ assertThat(viewModel.state.value.recipientId).isNull()
+ }
+
+ //endregion
+
+ /** A recording is the camera's own business, so the flow around it is not told. */
+ @Test
+ fun `Given a recording is running, when reported, then the flow is left alone`() = runTest {
+ createViewModel().onEvent(camera(CameraXScreenEvents.RecordingStateChanged(isRecording = true)))
+ advanceUntilIdle()
+
+ assertThat(parentEvents).isEmpty()
+ }
+
@Test
fun `Given nothing has happened, when created, then the flow is left alone`() = runTest {
createViewModel()
@@ -183,14 +352,15 @@ class MediaCaptureViewModelTest {
//region Handing off to the flow
/**
- * Everything the flow, rather than this screen, is responsible for. Kept as one table so that an event added to the
- * screen without a home in the flow's own vocabulary shows up as a gap here.
+ * Everything the flow, rather than this screen, is responsible for. Kept as one table so an event added to the screen
+ * with no counterpart in the flow's vocabulary shows up as a gap here.
*/
@Test
fun `Given work only the flow can do, when it is asked for, then it is handed over unchanged`() = runTest {
val handOffs: List> = listOf(
- MediaCaptureScreenEvents.ShowCamera to MediaSendFlowEvent.NavigateToCamera,
- MediaCaptureScreenEvents.ShowTextStory to MediaSendFlowEvent.NavigateToTextStory,
+ MediaCaptureScreenEvents.CaptureModeSelected(MediaCaptureMode.PHOTO) to MediaSendFlowEvent.NavigateToCamera,
+ MediaCaptureScreenEvents.CaptureModeSelected(MediaCaptureMode.VIDEO) to MediaSendFlowEvent.NavigateToCamera,
+ MediaCaptureScreenEvents.CaptureModeSelected(MediaCaptureMode.TEXT_STORY) to MediaSendFlowEvent.NavigateToTextStory,
MediaCaptureScreenEvents.NextClicked to MediaSendFlowEvent.NavigateToEdit,
camera(CameraXScreenEvents.GalleryClicked) to MediaSendFlowEvent.NavigateToFolders,
camera(CameraXScreenEvents.CameraCloseClicked) to MediaSendFlowEvent.CloseRequested,
@@ -236,7 +406,7 @@ class MediaCaptureViewModelTest {
assertThat(parentEvents).containsExactly(snackbar(R.string.MediaSendViewModel__error_taking_photo))
}
- /** The duration rides along because it is what the flow drops back to standard quality on. */
+ /** The duration comes along because the flow uses it to decide whether to drop to standard quality. */
@Test
fun `when a recording is captured, then it is handed over with how long it ran`() = runTest {
coEvery { repository.writeCapturedVideo(any()) } returns MEDIA
@@ -289,6 +459,8 @@ class MediaCaptureViewModelTest {
}
private companion object {
+ private val RECIPIENT_ID = MediaRecipientId(id = 7L)
+
private val MEDIA = Media(
uri = "content://capture".toUri(),
contentType = "image/jpeg",
diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/select/MediaSelectScreenNextButtonTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/select/MediaSelectScreenNextButtonTest.kt
new file mode 100644
index 0000000000..60303a9309
--- /dev/null
+++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/select/MediaSelectScreenNextButtonTest.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.screens.select
+
+import android.app.Application
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.test.core.app.ApplicationProvider
+import assertk.assertThat
+import assertk.assertions.contains
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.signal.core.models.media.Media
+import org.signal.core.models.media.MediaFolder
+import org.signal.core.ui.CoreUiDependenciesRule
+import org.signal.core.ui.compose.theme.SignalTheme
+import org.signal.mediasend.MediaSendDependenciesRule
+import org.signal.mediasend.test.TestTags
+
+/**
+ * Covers the picker's use of the button it shares with the capture screen. The button itself is covered where it lives;
+ * what matters here is that this screen puts it up, that it reads this screen's selection, and that it raises what this
+ * screen expects rather than what the capture screen does.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, qualifiers = "w400dp-h800dp")
+class MediaSelectScreenNextButtonTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @get:Rule
+ val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
+
+ @get:Rule
+ val mediaSendDependenciesRule = MediaSendDependenciesRule(ApplicationProvider.getApplicationContext())
+
+ private val events = mutableListOf()
+
+ @Test
+ fun `Given a selection, when displayed, then the button says how much is in it`() {
+ setContent(selectedMedia = MEDIA.take(3))
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_NEXT_BUTTON).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_MEDIA_COUNT).assertTextEquals("3")
+ }
+
+ /** The picker moves on to the editor, which is a different event from the capture screen's. */
+ @Test
+ fun `Given a selection, when the button is clicked, then the editor is asked for`() {
+ setContent(selectedMedia = MEDIA.take(1))
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_NEXT_BUTTON).performClick()
+
+ assertThat(events).contains(MediaSelectScreenEvents.NavigateToEdit)
+ }
+
+ @Test
+ fun `Given nothing is selected, when displayed, then there is no button to move on with`() {
+ setContent(selectedMedia = emptyList())
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_NEXT_BUTTON).assertDoesNotExist()
+ }
+
+ private fun setContent(selectedMedia: List) {
+ composeTestRule.setContent {
+ SignalTheme {
+ Box(modifier = Modifier.size(SCREEN_WIDTH.dp, SCREEN_HEIGHT.dp)) {
+ MediaSelectScreen(
+ state = MediaSelectState.Files(
+ selectedMediaFolder = FOLDER,
+ selectedMediaFolderItems = MEDIA,
+ selectedMedia = selectedMedia
+ ),
+ onEvent = { events += it }
+ )
+ }
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ }
+
+ private companion object {
+ private const val SCREEN_WIDTH = 400f
+ private const val SCREEN_HEIGHT = 800f
+
+ private val FOLDER = MediaFolder(
+ thumbnailUri = "content://folder".toUri(),
+ title = "Camera",
+ itemCount = 8,
+ bucketId = "bucket",
+ folderType = MediaFolder.FolderType.CAMERA
+ )
+
+ private val MEDIA: List = (0 until 8).map { index ->
+ Media(
+ uri = "content://media/$index".toUri(),
+ contentType = "image/jpeg",
+ date = index.toLong(),
+ width = 100,
+ height = 100,
+ size = 1024,
+ duration = 0,
+ isBorderless = false,
+ isVideoGif = false,
+ bucketId = "bucket",
+ caption = null,
+ transformProperties = null,
+ fileName = null
+ )
+ }
+ }
+}
diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/shared/NextButtonTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/shared/NextButtonTest.kt
new file mode 100644
index 0000000000..9549f44424
--- /dev/null
+++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/shared/NextButtonTest.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.screens.shared
+
+import android.app.Application
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.test.core.app.ApplicationProvider
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.signal.core.ui.CoreUiDependenciesRule
+import org.signal.core.ui.compose.theme.SignalTheme
+import org.signal.mediasend.test.TestTags
+
+/**
+ * Covers the button both capture screens and the picker share: what it reads, and what it raises.
+ *
+ * How it is laid out is left to the snapshots. Nothing here measures the button, since a measurement only proves a
+ * number survived the layout, not that the button looks right.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, qualifiers = "w400dp-h800dp")
+class NextButtonTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @get:Rule
+ val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
+
+ private var clicks = 0
+
+ @Test
+ fun `Given a selection, when displayed, then the count says how much is waiting`() {
+ setContent(selectedMediaCount = 3)
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_NEXT_BUTTON).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_MEDIA_COUNT).assertTextEquals("3")
+ }
+
+ @Test
+ fun `when the button is clicked, then moving on is asked for`() {
+ setContent()
+
+ composeTestRule.onNodeWithTag(TestTags.MEDIA_SEND_NEXT_BUTTON).performClick()
+
+ assertThat(clicks).isEqualTo(1)
+ }
+
+ private fun setContent(selectedMediaCount: Int = 1) {
+ composeTestRule.setContent {
+ SignalTheme {
+ NextButton(
+ selectedMediaCount = selectedMediaCount,
+ onClick = { clicks++ }
+ )
+ }
+ }
+
+ composeTestRule.waitForIdle()
+ }
+}