mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-14 12:10:36 +01:00
Switch linked device scanner to use CameraScreen.
This commit is contained in:
@@ -8,22 +8,28 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import org.signal.camera.CameraScreenEvents
|
||||
import org.signal.camera.CameraScreenState
|
||||
import org.signal.camera.CameraScreenViewModel
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
@@ -48,6 +54,9 @@ class AddLinkDeviceFragment : ComposeFragment() {
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val cameraViewModel: CameraScreenViewModel = viewModel { CameraScreenViewModel() }
|
||||
val cameraState by cameraViewModel.state
|
||||
val context = LocalContext.current
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
|
||||
@@ -60,18 +69,23 @@ class AddLinkDeviceFragment : ComposeFragment() {
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
MainScreen(
|
||||
state = state,
|
||||
navController = navController,
|
||||
hasPermissions = cameraPermissionState.status.isGranted,
|
||||
onRequestPermissions = { askPermissions() },
|
||||
onShowFrontCamera = { viewModel.showFrontCamera() },
|
||||
onQrCodeScanned = { data ->
|
||||
LaunchedEffect(cameraViewModel) {
|
||||
cameraViewModel.qrCodeDetected.collect { data ->
|
||||
if (VibrateUtil.isHapticFeedbackEnabled(requireContext())) {
|
||||
VibrateUtil.vibrate(requireContext(), VIBRATE_DURATION_MS)
|
||||
}
|
||||
viewModel.onQrCodeScanned(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
MainScreen(
|
||||
state = state,
|
||||
cameraState = cameraState,
|
||||
cameraEmitter = cameraViewModel::onEvent,
|
||||
navController = navController,
|
||||
hasPermissions = cameraPermissionState.status.isGranted,
|
||||
onRequestPermissions = { askPermissions() },
|
||||
onSwitchCamera = { cameraViewModel.onEvent(CameraScreenEvents.SwitchCamera(context)) },
|
||||
onQrCodeApproved = {
|
||||
navController.popBackStack()
|
||||
viewModel.addDevice(shouldSync = false)
|
||||
@@ -102,11 +116,12 @@ class AddLinkDeviceFragment : ComposeFragment() {
|
||||
@Composable
|
||||
private fun MainScreen(
|
||||
state: LinkDeviceSettingsState,
|
||||
cameraState: CameraScreenState = CameraScreenState(),
|
||||
cameraEmitter: (CameraScreenEvents) -> Unit = {},
|
||||
navController: NavController? = null,
|
||||
hasPermissions: Boolean = false,
|
||||
onRequestPermissions: () -> Unit = {},
|
||||
onShowFrontCamera: () -> Unit = {},
|
||||
onQrCodeScanned: (String) -> Unit = {},
|
||||
onSwitchCamera: () -> Unit = {},
|
||||
onQrCodeApproved: () -> Unit = {},
|
||||
onQrCodeDismissed: () -> Unit = {},
|
||||
onLinkDeviceSuccess: () -> Unit = {},
|
||||
@@ -118,7 +133,7 @@ private fun MainScreen(
|
||||
navigationIcon = ImageVector.vectorResource(id = R.drawable.ic_x),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close),
|
||||
actions = {
|
||||
IconButton(onClick = { onShowFrontCamera() }) {
|
||||
IconButton(onClick = onSwitchCamera) {
|
||||
Icon(painterResource(id = R.drawable.symbol_switch_24), contentDescription = null)
|
||||
}
|
||||
}
|
||||
@@ -126,9 +141,9 @@ private fun MainScreen(
|
||||
LinkDeviceQrScanScreen(
|
||||
hasPermission = hasPermissions,
|
||||
onRequestPermissions = onRequestPermissions,
|
||||
showFrontCamera = state.showFrontCamera,
|
||||
cameraState = cameraState,
|
||||
cameraEmitter = cameraEmitter,
|
||||
qrCodeState = state.qrCodeState,
|
||||
onQrCodeScanned = onQrCodeScanned,
|
||||
onQrCodeAccepted = onQrCodeApproved,
|
||||
onQrCodeDismissed = onQrCodeDismissed,
|
||||
linkDeviceResult = state.linkDeviceResult,
|
||||
|
||||
+160
-27
@@ -2,25 +2,46 @@ package org.thoughtcrime.securesms.linkdevice
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import org.signal.camera.CameraCaptureMode
|
||||
import org.signal.camera.CameraScreen
|
||||
import org.signal.camera.CameraScreenEvents
|
||||
import org.signal.camera.CameraScreenState
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.qr.QrScannerView
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.qr.QrScanScreens
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* A screen that allows you to scan a QR code to link a device
|
||||
@@ -29,9 +50,9 @@ import java.util.concurrent.TimeUnit
|
||||
fun LinkDeviceQrScanScreen(
|
||||
hasPermission: Boolean,
|
||||
onRequestPermissions: () -> Unit,
|
||||
showFrontCamera: Boolean?,
|
||||
cameraState: CameraScreenState,
|
||||
cameraEmitter: (CameraScreenEvents) -> Unit,
|
||||
qrCodeState: LinkDeviceSettingsState.QrCodeState,
|
||||
onQrCodeScanned: (String) -> Unit,
|
||||
onQrCodeAccepted: () -> Unit,
|
||||
onQrCodeDismissed: () -> Unit,
|
||||
linkDeviceResult: LinkDeviceResult,
|
||||
@@ -40,7 +61,6 @@ fun LinkDeviceQrScanScreen(
|
||||
navController: NavController?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
|
||||
when (qrCodeState) {
|
||||
@@ -92,32 +112,145 @@ fun LinkDeviceQrScanScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, true)
|
||||
.background(Color.Black)
|
||||
) {
|
||||
QrScanScreens.QrScanScreen(
|
||||
factory = { factoryContext ->
|
||||
val view = QrScannerView(factoryContext)
|
||||
view.qrData
|
||||
.throttleFirst(3000, TimeUnit.MILLISECONDS)
|
||||
.subscribe { data ->
|
||||
onQrCodeScanned(data)
|
||||
}
|
||||
view
|
||||
},
|
||||
update = { view: QrScannerView ->
|
||||
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted())
|
||||
if (showFrontCamera != null) {
|
||||
view.toggleCamera()
|
||||
if (hasPermission) {
|
||||
val crosshairPath = remember { Path() }
|
||||
|
||||
CameraScreen(
|
||||
state = cameraState,
|
||||
emitter = cameraEmitter,
|
||||
enableQrScanning = true,
|
||||
captureMode = CameraCaptureMode.ImageOnly,
|
||||
roundCorners = false,
|
||||
fillViewport = true,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawQrCrosshair(crosshairPath)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.AddLinkDeviceFragment__scan_the_qr_code),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 24.dp)
|
||||
.background(color = Color.Black.copy(alpha = 0.5f), shape = CircleShape)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(48.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.CameraXFragment_to_scan_qr_code_allow_camera),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color.White
|
||||
)
|
||||
Buttons.MediumTonal(
|
||||
colors = ButtonDefaults.filledTonalButtonColors(),
|
||||
onClick = onRequestPermissions
|
||||
) {
|
||||
Text(stringResource(R.string.CameraXFragment_allow_access))
|
||||
}
|
||||
},
|
||||
hasPermission = hasPermission,
|
||||
onRequestPermissions = onRequestPermissions,
|
||||
qrHeaderLabelString = stringResource(R.string.AddLinkDeviceFragment__scan_the_qr_code)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawQrCrosshair(path: Path) {
|
||||
val crosshairWidth: Float = size.minDimension * 0.6f
|
||||
val crosshairLineLength = crosshairWidth * 0.125f
|
||||
|
||||
val topLeft = center - Offset(crosshairWidth / 2, crosshairWidth / 2)
|
||||
val topRight = center + Offset(crosshairWidth / 2, -crosshairWidth / 2)
|
||||
val bottomRight = center + Offset(crosshairWidth / 2, crosshairWidth / 2)
|
||||
val bottomLeft = center + Offset(-crosshairWidth / 2, crosshairWidth / 2)
|
||||
|
||||
path.reset()
|
||||
|
||||
drawPath(
|
||||
path = path.apply {
|
||||
moveTo(topLeft.x, topLeft.y + crosshairLineLength)
|
||||
lineTo(topLeft.x, topLeft.y)
|
||||
lineTo(topLeft.x + crosshairLineLength, topLeft.y)
|
||||
|
||||
moveTo(topRight.x - crosshairLineLength, topRight.y)
|
||||
lineTo(topRight.x, topRight.y)
|
||||
lineTo(topRight.x, topRight.y + crosshairLineLength)
|
||||
|
||||
moveTo(bottomRight.x, bottomRight.y - crosshairLineLength)
|
||||
lineTo(bottomRight.x, bottomRight.y)
|
||||
lineTo(bottomRight.x - crosshairLineLength, bottomRight.y)
|
||||
|
||||
moveTo(bottomLeft.x + crosshairLineLength, bottomLeft.y)
|
||||
lineTo(bottomLeft.x, bottomLeft.y)
|
||||
lineTo(bottomLeft.x, bottomLeft.y - crosshairLineLength)
|
||||
},
|
||||
color = Color.White,
|
||||
style = Stroke(
|
||||
width = 3.dp.toPx(),
|
||||
pathEffect = PathEffect.cornerPathEffect(10.dp.toPx())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeToast(context: Context, messageId: Int, onLinkDeviceFailure: () -> Unit) {
|
||||
Toast.makeText(context, messageId, Toast.LENGTH_LONG).show()
|
||||
onLinkDeviceFailure()
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun LinkDeviceQrScanScreenPreview() {
|
||||
Previews.Preview {
|
||||
LinkDeviceQrScanScreen(
|
||||
hasPermission = true,
|
||||
onRequestPermissions = {},
|
||||
cameraState = CameraScreenState(),
|
||||
cameraEmitter = {},
|
||||
qrCodeState = LinkDeviceSettingsState.QrCodeState.NONE,
|
||||
onQrCodeAccepted = {},
|
||||
onQrCodeDismissed = {},
|
||||
linkDeviceResult = LinkDeviceResult.None,
|
||||
onLinkDeviceSuccess = {},
|
||||
onLinkDeviceFailure = {},
|
||||
navController = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun LinkDeviceQrScanScreenNoPermissionPreview() {
|
||||
Previews.Preview {
|
||||
LinkDeviceQrScanScreen(
|
||||
hasPermission = false,
|
||||
onRequestPermissions = {},
|
||||
cameraState = CameraScreenState(),
|
||||
cameraEmitter = {},
|
||||
qrCodeState = LinkDeviceSettingsState.QrCodeState.NONE,
|
||||
onQrCodeAccepted = {},
|
||||
onQrCodeDismissed = {},
|
||||
linkDeviceResult = LinkDeviceResult.None,
|
||||
onLinkDeviceSuccess = {},
|
||||
onLinkDeviceFailure = {},
|
||||
navController = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ data class LinkDeviceSettingsState(
|
||||
val dialogState: DialogState = DialogState.None,
|
||||
val deviceListLoading: Boolean = false,
|
||||
val oneTimeEvent: OneTimeEvent = OneTimeEvent.None,
|
||||
val showFrontCamera: Boolean? = null,
|
||||
val qrCodeState: QrCodeState = QrCodeState.NONE,
|
||||
val linkUri: Uri? = null,
|
||||
val linkDeviceResult: LinkDeviceResult = LinkDeviceResult.None,
|
||||
|
||||
@@ -105,8 +105,7 @@ class LinkDeviceViewModel : ViewModel() {
|
||||
|
||||
private fun loadDevices(initialLoad: Boolean = false) {
|
||||
_state.value = _state.value.copy(
|
||||
deviceListLoading = true,
|
||||
showFrontCamera = null
|
||||
deviceListLoading = true
|
||||
)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -153,21 +152,11 @@ class LinkDeviceViewModel : ViewModel() {
|
||||
pollJob?.cancel()
|
||||
}
|
||||
|
||||
fun showFrontCamera() {
|
||||
_state.update {
|
||||
val frontCamera = it.showFrontCamera
|
||||
it.copy(
|
||||
showFrontCamera = if (frontCamera == null) true else !frontCamera
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markQrEducationSheetSeen() {
|
||||
SignalStore.uiHints.markHasSeenLinkDeviceQrEducationSheet()
|
||||
_state.update {
|
||||
it.copy(
|
||||
seenQrEducationSheet = true,
|
||||
showFrontCamera = null
|
||||
seenQrEducationSheet = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -182,16 +171,14 @@ class LinkDeviceViewModel : ViewModel() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
qrCodeState = if (uri.supportsLinkAndSync()) QrCodeState.VALID_WITH_SYNC else QrCodeState.VALID_WITHOUT_SYNC,
|
||||
linkUri = uri,
|
||||
showFrontCamera = null
|
||||
linkUri = uri
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
qrCodeState = QrCodeState.INVALID,
|
||||
linkUri = uri,
|
||||
showFrontCamera = null
|
||||
linkUri = uri
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
@@ -66,6 +67,8 @@ import androidx.camera.core.Preview as CameraPreview
|
||||
* @param modifier Modifier to apply to the camera container.
|
||||
* @param roundCorners Whether to apply rounded corners to the camera viewfinder. Defaults to true.
|
||||
* @param contentAlignment The alignment of the camera viewfinder within the available space. Defaults to center.
|
||||
* @param fillViewport When true, the viewfinder fills all available space, cropping the camera frame
|
||||
* if necessary to avoid letterbox bars. Defaults to false (letterbox to a 9:16 / 16:9 aspect ratio).
|
||||
* @param content Composable content to overlay on top of the camera surface. The content is placed in a Box
|
||||
* with the same size and position as the camera surface.
|
||||
*/
|
||||
@@ -78,6 +81,7 @@ fun CameraScreen(
|
||||
contentAlignment: Alignment = Alignment.Center,
|
||||
captureMode: CameraCaptureMode = CameraCaptureMode.ImageAndVideoSimultaneous,
|
||||
enableQrScanning: Boolean = false,
|
||||
fillViewport: Boolean = false,
|
||||
content: @Composable BoxScope.() -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -92,8 +96,10 @@ fun CameraScreen(
|
||||
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val aspectRatio = if (isLandscape) 16f / 9f else 9f / 16f
|
||||
|
||||
// Bind camera and setup surface provider
|
||||
LaunchedEffect(lifecycleOwner, state.lensFacing) {
|
||||
// Bind camera and setup surface provider. Re-bind on orientation changes so the
|
||||
// Preview's target rotation matches the new display rotation — otherwise the surface
|
||||
// is set up for the previous orientation and the frame ends up rotated/stretched.
|
||||
LaunchedEffect(lifecycleOwner, state.lensFacing, configuration.orientation) {
|
||||
val cameraProvider = ProcessCameraProvider.getInstance(context).get()
|
||||
|
||||
val surfaceProvider = CameraPreview.SurfaceProvider { request ->
|
||||
@@ -120,9 +126,14 @@ fun CameraScreen(
|
||||
val availableAspectRatio = maxWidth / maxHeight
|
||||
val matchHeightFirst = availableAspectRatio > aspectRatio
|
||||
|
||||
val viewfinderBoxModifier = if (fillViewport) {
|
||||
Modifier.fillMaxSize()
|
||||
} else {
|
||||
Modifier.aspectRatio(aspectRatio, matchHeightConstraintsFirst = matchHeightFirst)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(aspectRatio, matchHeightConstraintsFirst = matchHeightFirst)
|
||||
modifier = viewfinderBoxModifier
|
||||
) {
|
||||
val cornerShape = if (roundCorners) RoundedCornerShape(16.dp) else RoundedCornerShape(0.dp)
|
||||
|
||||
@@ -143,6 +154,7 @@ fun CameraScreen(
|
||||
CameraXViewfinder(
|
||||
surfaceRequest = currentSurfaceRequest,
|
||||
coordinateTransformer = coordinateTransformer,
|
||||
contentScale = if (fillViewport) ContentScale.Crop else ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(cornerShape)
|
||||
|
||||
Reference in New Issue
Block a user