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