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