Add ability to slide to lock video.

This commit is contained in:
Alex Hart
2026-08-26 09:21:33 -03:00
committed by GitHub
parent c1453fab77
commit 96bd5f5185
45 changed files with 4381 additions and 712 deletions
@@ -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)
@@ -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)),
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M6 5c0-0.83 0.67-1.5 1.5-1.5H8c0.83 0 1.5 0.67 1.5 1.5v14c0 0.83-0.67 1.5-1.5 1.5H7.5C6.67 20.5 6 19.83 6 19V5Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M14.5 5c0-0.83 0.67-1.5 1.5-1.5h0.5C17.33 3.5 18 4.17 18 5v14c0 0.83-0.67 1.5-1.5 1.5H16c-0.83 0-1.5-0.67-1.5-1.5V5Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M21.12 10.17c1.4 0.82 1.4 2.84 0 3.66L7.96 21.6c-1.42 0.84-3.21-0.19-3.21-1.83V4.23c0-1.64 1.79-2.67 3.2-1.83l13.17 7.77Zm-0.89 2.15c0.25-0.14 0.25-0.5 0-0.64L7.07 3.9C6.82 3.76 6.5 3.94 6.5 4.23v15.54c0 0.29 0.32 0.47 0.57 0.32l13.16-7.77Z"/>
</vector>
@@ -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
}
+1
View File
@@ -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)
}
@@ -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
@@ -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<Float> = 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,
@@ -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<CameraScreenState> = 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<String>(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<ZoomState>? = null
private val zoomRangeObserver = Observer<ZoomState> { 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, Any>(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])
@@ -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<Float>(SWAP_DURATION_MS)
return (scaleIn(initialScale = SWAP_SCALE, animationSpec = spec) + fadeIn(animationSpec = spec))
.togetherWith(scaleOut(targetScale = SWAP_SCALE, animationSpec = spec) + fadeOut(animationSpec = spec))
}
}
@@ -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<Dp>(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium)
private val ColorSpec = tween<Color>(durationMillis = 200)
private val PressSpec = spring<Float>(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<Dp>(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")
}
}
}
@@ -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
}
@@ -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) {
@@ -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 = {})
}
}
@@ -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() {
@@ -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.
*/
@@ -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<Float>,
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<ZoomBarLevel>,
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<Float> = 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
)
}
}
@@ -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<Float>, cameraDisplay: CameraDisplay): List<ZoomBarLevel> = 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<ZoomBarLevel> = 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>): 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<Float>): 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
}
}
@@ -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"
}
@@ -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<ZoomState> = 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<ZoomState>()
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<Any?>.hasImageAnalysis() = any { it is ImageAnalysis }
private fun List<Any?>.imageCapture() = filterIsInstance<ImageCapture>().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<ZoomState>().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
}
}
@@ -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
)
}
@@ -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<String>()
private val zoomChanges = mutableListOf<Float>()
private val haptics = mutableListOf<HapticFeedbackType>()
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)
}
}
@@ -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()
}
}
@@ -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<StandardCameraHudEvents>()
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
}
}
@@ -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
}
}
@@ -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<ZoomBarLevel>()
@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<Float> = 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()
}
}
@@ -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()
}
@@ -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
@@ -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)) },
@@ -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)
}
@@ -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<Float>(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<MediaCaptureMode>,
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)
)
}
}
}
@@ -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)
}
}
@@ -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<Media>,
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<Media>,
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
}
}
)
}
}
@@ -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. */
@@ -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<MediaCaptureMode>
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
}
@@ -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)
@@ -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<MediaFolder> {
return remember(count) {
@@ -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))
}
}
@@ -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 }
}
@@ -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"
@@ -85,15 +85,12 @@
<!-- Snackbar shown when the user denies the permission to read their photos and videos. -->
<string name="MediaSelectScreen__signal_needs_access_to_show_your_photos_and_videos">Signal needs access to show your photos and videos.</string>
<!-- Label for the button that switches the capture screen to the camera. -->
<string name="MediaCaptureScreen__camera">Camera</string>
<!-- Label for the button that switches the capture screen to taking a photo. -->
<string name="MediaCaptureScreen__photo">Photo</string>
<!-- Label for the button that switches the capture screen to recording a video. -->
<string name="MediaCaptureScreen__video">Video</string>
<!-- Label for the button that switches the capture screen to the text story editor. -->
<string name="MediaCaptureScreen__text">Text</string>
<!-- Label for the pill that displays the media count -->
<plurals name="MediaCaptureScreen_n_items">
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d items</item>
</plurals>
<!-- Content description for the button that attaches a link to the text story being created -->
<string name="TextStoryBar__add_link">Add link</string>
@@ -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<MediaCaptureScreenEvents>()
@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<MediaCaptureMode>
get() = events.filterIsInstance<MediaCaptureScreenEvents.CaptureModeSelected>().map { it.mode }
/** Everything the chrome asked for: the same stream with the camera's own reports removed. */
private val chromeRequests: List<MediaCaptureScreenEvents>
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,
@@ -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<Pair<MediaCaptureScreenEvents, MediaSendFlowEvent>> = 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",
@@ -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<MediaSelectScreenEvents>()
@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<Media>) {
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<Media> = (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
)
}
}
}
@@ -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()
}
}