mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-27 05:03:28 +00:00
Update add linked devices screen.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package org.thoughtcrime.securesms.linkdevice
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.fragment.app.activityViewModels
|
||||
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.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
|
||||
import org.thoughtcrime.securesms.BiometricDeviceLockContract
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Fragment that allows users to scan a QR code from their camera to link a device
|
||||
*/
|
||||
class AddLinkDeviceFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(AddLinkDeviceFragment::class)
|
||||
}
|
||||
|
||||
private val viewModel: LinkDeviceViewModel by activityViewModels()
|
||||
private lateinit var biometricAuth: BiometricDeviceAuthentication
|
||||
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
|
||||
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
|
||||
viewModel.addDevice()
|
||||
} else {
|
||||
viewModel.clearBiometrics()
|
||||
}
|
||||
}
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
|
||||
.setTitle(requireContext().getString(R.string.BiometricDeviceAuthentication__signal))
|
||||
.setConfirmationRequired(true)
|
||||
.build()
|
||||
biometricAuth = BiometricDeviceAuthentication(
|
||||
BiometricManager.from(requireActivity()),
|
||||
BiometricPrompt(requireActivity(), BiometricAuthenticationListener()),
|
||||
promptInfo
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
viewModel.clearBiometrics()
|
||||
biometricAuth.cancelAuthentication()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||
|
||||
if (!state.seenIntroSheet) {
|
||||
navController.safeNavigate(R.id.action_addLinkDeviceFragment_to_linkDeviceIntroBottomSheet)
|
||||
viewModel.markIntroSheetSeen()
|
||||
}
|
||||
|
||||
if ((state.qrCodeFound || state.qrCodeInvalid) && navController.currentDestination?.id == R.id.linkDeviceIntroBottomSheet) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
MainScreen(
|
||||
state = state,
|
||||
navController = navController,
|
||||
hasPermissions = cameraPermissionState.status.isGranted,
|
||||
onRequestPermissions = { askPermissions() },
|
||||
onShowFrontCamera = { viewModel.showFrontCamera() },
|
||||
onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) },
|
||||
onQrCodeApproved = {
|
||||
viewModel.onQrCodeApproved()
|
||||
biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.BiometricDeviceAuthentication__signal)) }
|
||||
},
|
||||
onQrCodeDismissed = { viewModel.onQrCodeDismissed() },
|
||||
onQrCodeRetry = { viewModel.onQrCodeScanned(state.url) },
|
||||
onLinkDeviceSuccess = {
|
||||
viewModel.onLinkDeviceResult(true)
|
||||
navController.popBackStack()
|
||||
},
|
||||
onLinkDeviceFailure = { viewModel.onLinkDeviceResult(false) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun askPermissions() {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.CAMERA)
|
||||
.ifNecessary()
|
||||
.withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_scan_qr_codes, parentFragmentManager)
|
||||
.onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
|
||||
.execute()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
|
||||
Log.w(TAG, "Linked device authentication error: $errorCode")
|
||||
viewModel.clearBiometrics()
|
||||
onAuthenticationFailed()
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
Log.i(TAG, "Linked device authentication succeeded")
|
||||
viewModel.addDevice()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
Log.w(TAG, "Linked device unable to authenticate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScreen(
|
||||
state: LinkDeviceSettingsState,
|
||||
navController: NavController? = null,
|
||||
hasPermissions: Boolean = false,
|
||||
onRequestPermissions: () -> Unit = {},
|
||||
onShowFrontCamera: () -> Unit = {},
|
||||
onQrCodeScanned: (String) -> Unit = {},
|
||||
onQrCodeApproved: () -> Unit = {},
|
||||
onQrCodeDismissed: () -> Unit = {},
|
||||
onQrCodeRetry: () -> Unit = {},
|
||||
onLinkDeviceSuccess: () -> Unit = {},
|
||||
onLinkDeviceFailure: () -> Unit = {}
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
onNavigationClick = { navController?.popBackStack() },
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_x),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close),
|
||||
actions = {
|
||||
IconButton(onClick = { onShowFrontCamera() }) {
|
||||
Icon(painterResource(id = R.drawable.symbol_switch_24), contentDescription = null)
|
||||
}
|
||||
}
|
||||
) { contentPadding: PaddingValues ->
|
||||
LinkDeviceQrScanScreen(
|
||||
hasPermission = hasPermissions,
|
||||
onRequestPermissions = onRequestPermissions,
|
||||
showFrontCamera = state.showFrontCamera,
|
||||
qrCodeFound = state.qrCodeFound,
|
||||
qrCodeInvalid = state.qrCodeInvalid,
|
||||
onQrCodeScanned = onQrCodeScanned,
|
||||
onQrCodeAccepted = onQrCodeApproved,
|
||||
onQrCodeDismissed = onQrCodeDismissed,
|
||||
onQrCodeRetry = onQrCodeRetry,
|
||||
pendingBiometrics = state.pendingBiometrics,
|
||||
linkDeviceResult = state.linkDeviceResult,
|
||||
onLinkDeviceSuccess = onLinkDeviceSuccess,
|
||||
onLinkDeviceFailure = onLinkDeviceFailure,
|
||||
modifier = Modifier.padding(contentPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun LinkDeviceAddScreenPreview() {
|
||||
Previews.Preview {
|
||||
MainScreen(
|
||||
state = LinkDeviceSettingsState()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.linkdevice
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material3.Icon
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
|
||||
/**
|
||||
* Bottom sheet dialog prompting users to name their newly linked device
|
||||
*/
|
||||
class LinkDeviceFinishedSheet : ComposeBottomSheetDialogFragment() {
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
FinishedSheet(this::dismissAllowingStateLoss)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FinishedSheet(onClick: () -> Unit) {
|
||||
return Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentSize(Alignment.Center)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_devices),
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.AddLinkDeviceFragment__finish_linking_on_other_device),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.AddLinkDeviceFragment__finish_linking_signal),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Buttons.LargeTonal(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.defaultMinSize(minWidth = 220.dp).padding(vertical = 20.dp, horizontal = 12.dp)
|
||||
) {
|
||||
Text(stringResource(id = R.string.AddLinkDeviceFragment__okay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
fun FinishedSheetSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
FinishedSheet(onClick = {})
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -34,7 +35,7 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
@@ -42,11 +43,11 @@ import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.DeviceActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -54,11 +55,11 @@ import java.util.Locale
|
||||
*/
|
||||
class LinkDeviceFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: LinkDeviceViewModel by viewModels()
|
||||
private val viewModel: LinkDeviceViewModel by activityViewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(state.toastDialog) {
|
||||
if (state.toastDialog.isNotEmpty()) {
|
||||
@@ -66,6 +67,12 @@ class LinkDeviceFragment : ComposeFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.showFinishedSheet) {
|
||||
if (state.showFinishedSheet) {
|
||||
onShowFinishedSheet()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.preferences__linked_devices),
|
||||
onNavigationClick = { findNavController().popBackStack() },
|
||||
@@ -93,9 +100,7 @@ class LinkDeviceFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
private fun openLinkNewDevice() {
|
||||
// TODO(Michelle): Use linkDeviceAddFragment
|
||||
startActivity(DeviceActivity.getIntentForScanner(requireContext()))
|
||||
// findNavController().safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceAddFragment)
|
||||
findNavController().safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment)
|
||||
}
|
||||
|
||||
private fun setDeviceToRemove(device: Device?) {
|
||||
@@ -105,6 +110,11 @@ class LinkDeviceFragment : ComposeFragment() {
|
||||
private fun onRemoveDevice(device: Device) {
|
||||
viewModel.removeDevice(requireContext(), device)
|
||||
}
|
||||
|
||||
private fun onShowFinishedSheet() {
|
||||
LinkDeviceFinishedSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
viewModel.markFinishedSheetSeen()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.thoughtcrime.securesms.linkdevice
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
|
||||
/**
|
||||
* Bottom sheet dialog displayed when users click 'Link a device'
|
||||
*/
|
||||
class LinkDeviceIntroBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.8f
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
EducationSheet(this::dismissAllowingStateLoss)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EducationSheet(onClick: () -> Unit) {
|
||||
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.linking_device))
|
||||
|
||||
return Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
Box(modifier = Modifier.size(150.dp)) {
|
||||
LottieAnimation(composition, iterations = LottieConstants.IterateForever, modifier = Modifier.matchParentSize())
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.AddLinkDeviceFragment__scan_qr_code),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.AddLinkDeviceFragment__use_this_device_to_scan_qr_code),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
Buttons.LargeTonal(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(stringResource(id = R.string.AddLinkDeviceFragment__okay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
fun EducationSheetPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
EducationSheet(onClick = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.thoughtcrime.securesms.linkdevice
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.qr.QrScannerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
|
||||
import org.thoughtcrime.securesms.qr.QrScanScreens
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* A screen that allows you to scan a QR code to link a device
|
||||
*/
|
||||
@Composable
|
||||
fun LinkDeviceQrScanScreen(
|
||||
hasPermission: Boolean,
|
||||
onRequestPermissions: () -> Unit,
|
||||
showFrontCamera: Boolean?,
|
||||
qrCodeFound: Boolean,
|
||||
qrCodeInvalid: Boolean,
|
||||
onQrCodeScanned: (String) -> Unit,
|
||||
onQrCodeAccepted: () -> Unit,
|
||||
onQrCodeDismissed: () -> Unit,
|
||||
onQrCodeRetry: () -> Unit,
|
||||
pendingBiometrics: Boolean,
|
||||
linkDeviceResult: LinkDeviceRepository.LinkDeviceResult,
|
||||
onLinkDeviceSuccess: () -> Unit,
|
||||
onLinkDeviceFailure: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
|
||||
if (qrCodeFound) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.DeviceProvisioningActivity_link_this_device),
|
||||
body = stringResource(id = R.string.AddLinkDeviceFragment__this_device_will_see_your_groups_contacts),
|
||||
confirm = stringResource(id = R.string.device_list_fragment__link_new_device),
|
||||
onConfirm = onQrCodeAccepted,
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onDismiss = onQrCodeDismissed
|
||||
)
|
||||
} else if (qrCodeInvalid) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.AddLinkDeviceFragment__linking_device_failed),
|
||||
body = stringResource(id = R.string.AddLinkDeviceFragment__this_qr_code_not_valid),
|
||||
confirm = stringResource(id = R.string.AddLinkDeviceFragment__retry),
|
||||
onConfirm = onQrCodeRetry,
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onDismiss = onQrCodeDismissed
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(linkDeviceResult) {
|
||||
when (linkDeviceResult) {
|
||||
LinkDeviceRepository.LinkDeviceResult.SUCCESS -> onLinkDeviceSuccess()
|
||||
LinkDeviceRepository.LinkDeviceResult.NO_DEVICE -> makeToast(context, R.string.DeviceProvisioningActivity_content_progress_no_device, onLinkDeviceFailure)
|
||||
LinkDeviceRepository.LinkDeviceResult.NETWORK_ERROR -> makeToast(context, R.string.DeviceProvisioningActivity_content_progress_network_error, onLinkDeviceFailure)
|
||||
LinkDeviceRepository.LinkDeviceResult.KEY_ERROR -> makeToast(context, R.string.DeviceProvisioningActivity_content_progress_key_error, onLinkDeviceFailure)
|
||||
LinkDeviceRepository.LinkDeviceResult.LIMIT_EXCEEDED -> makeToast(context, R.string.DeviceProvisioningActivity_sorry_you_have_too_many_devices_linked_already, onLinkDeviceFailure)
|
||||
LinkDeviceRepository.LinkDeviceResult.BAD_CODE -> makeToast(context, R.string.DeviceActivity_sorry_this_is_not_a_valid_device_link_qr_code, onLinkDeviceFailure)
|
||||
LinkDeviceRepository.LinkDeviceResult.UNKNOWN -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, true)
|
||||
) {
|
||||
QrScanScreens.QrScanScreen(
|
||||
factory = { factoryContext ->
|
||||
val view = QrScannerView(factoryContext)
|
||||
view.qrData
|
||||
.throttleFirst(3000, TimeUnit.MILLISECONDS)
|
||||
.subscribe { data ->
|
||||
onQrCodeScanned(data)
|
||||
}
|
||||
view
|
||||
},
|
||||
update = { view: QrScannerView ->
|
||||
if (pendingBiometrics) {
|
||||
view.destroy()
|
||||
} else {
|
||||
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
|
||||
if (showFrontCamera != null) {
|
||||
view.toggleCamera()
|
||||
}
|
||||
}
|
||||
},
|
||||
hasPermission = hasPermission,
|
||||
onRequestPermissions = onRequestPermissions,
|
||||
qrString = stringResource(R.string.AddLinkDeviceFragment__scan_the_qr_code)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeToast(context: Context, messageId: Int, onLinkDeviceFailure: () -> Unit) {
|
||||
Toast.makeText(context, messageId, Toast.LENGTH_LONG).show()
|
||||
onLinkDeviceFailure()
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
package org.thoughtcrime.securesms.linkdevice
|
||||
|
||||
import android.net.Uri
|
||||
import org.signal.core.util.Base64.decode
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.devicelist.protos.DeviceName
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException
|
||||
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException
|
||||
import java.io.IOException
|
||||
import java.security.InvalidKeyException
|
||||
|
||||
/**
|
||||
* Repository for linked devices and its various actions (linking, unlinking, listing).
|
||||
@@ -71,4 +79,49 @@ object LinkDeviceRepository {
|
||||
}
|
||||
return defaultDevice
|
||||
}
|
||||
|
||||
fun isValidQr(uri: Uri): Boolean {
|
||||
val ephemeralId: String? = uri.getQueryParameter("uuid")
|
||||
val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
|
||||
return ephemeralId.isNotNullOrBlank() && publicKeyEncoded.isNotNullOrBlank()
|
||||
}
|
||||
|
||||
fun addDevice(uri: Uri): LinkDeviceResult {
|
||||
return try {
|
||||
val accountManager = AppDependencies.signalServiceAccountManager
|
||||
val verificationCode = accountManager.getNewDeviceVerificationCode()
|
||||
if (!isValidQr(uri)) {
|
||||
LinkDeviceResult.BAD_CODE
|
||||
} else {
|
||||
val ephemeralId: String? = uri.getQueryParameter("uuid")
|
||||
val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
|
||||
val publicKey = Curve.decodePoint(publicKeyEncoded?.let { decode(it) }, 0)
|
||||
val aciIdentityKeyPair = SignalStore.account().aciIdentityKey
|
||||
val pniIdentityKeyPair = SignalStore.account().pniIdentityKey
|
||||
val profileKey = ProfileKeyUtil.getSelfProfileKey()
|
||||
|
||||
accountManager.addDevice(ephemeralId, publicKey, aciIdentityKeyPair, pniIdentityKeyPair, profileKey, SignalStore.svr().getOrCreateMasterKey(), verificationCode)
|
||||
TextSecurePreferences.setMultiDevice(AppDependencies.application, true)
|
||||
LinkDeviceResult.SUCCESS
|
||||
}
|
||||
} catch (e: NotFoundException) {
|
||||
LinkDeviceResult.NO_DEVICE
|
||||
} catch (e: DeviceLimitExceededException) {
|
||||
LinkDeviceResult.LIMIT_EXCEEDED
|
||||
} catch (e: IOException) {
|
||||
LinkDeviceResult.NETWORK_ERROR
|
||||
} catch (e: InvalidKeyException) {
|
||||
LinkDeviceResult.KEY_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
enum class LinkDeviceResult {
|
||||
SUCCESS,
|
||||
NO_DEVICE,
|
||||
NETWORK_ERROR,
|
||||
KEY_ERROR,
|
||||
LIMIT_EXCEEDED,
|
||||
BAD_CODE,
|
||||
UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,13 @@ data class LinkDeviceSettingsState(
|
||||
val devices: List<Device> = emptyList(),
|
||||
val deviceToRemove: Device? = null,
|
||||
@StringRes val progressDialogMessage: Int = -1,
|
||||
val toastDialog: String = ""
|
||||
val toastDialog: String = "",
|
||||
val showFrontCamera: Boolean? = null,
|
||||
val qrCodeFound: Boolean = false,
|
||||
val qrCodeInvalid: Boolean = false,
|
||||
val url: String = "",
|
||||
val linkDeviceResult: LinkDeviceRepository.LinkDeviceResult = LinkDeviceRepository.LinkDeviceResult.UNKNOWN,
|
||||
val showFinishedSheet: Boolean = false,
|
||||
val seenIntroSheet: Boolean = false,
|
||||
val pendingBiometrics: Boolean = false
|
||||
)
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
package org.thoughtcrime.securesms.linkdevice
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
|
||||
|
||||
/**
|
||||
* Maintains the state of the [LinkDeviceFragment]
|
||||
*/
|
||||
class LinkDeviceViewModel : ViewModel() {
|
||||
|
||||
private val _state = mutableStateOf(LinkDeviceSettingsState())
|
||||
val state: State<LinkDeviceSettingsState> = _state
|
||||
|
||||
fun onResume() {
|
||||
_state.value = _state.value.copy()
|
||||
}
|
||||
private val _state = MutableStateFlow(LinkDeviceSettingsState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
fun setDeviceToRemove(device: Device?) {
|
||||
_state.value = _state.value.copy(deviceToRemove = device)
|
||||
_state.update { it.copy(deviceToRemove = device) }
|
||||
}
|
||||
|
||||
fun removeDevice(context: Context, device: Device) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(
|
||||
progressDialogMessage = R.string.DeviceListActivity_unlinking_device
|
||||
)
|
||||
_state.update { it.copy(progressDialogMessage = R.string.DeviceListActivity_unlinking_device) }
|
||||
|
||||
val success = LinkDeviceRepository.removeDevice(device.id)
|
||||
if (success) {
|
||||
loadDevices(context)
|
||||
@@ -38,9 +36,9 @@ class LinkDeviceViewModel : ViewModel() {
|
||||
progressDialogMessage = -1
|
||||
)
|
||||
} else {
|
||||
_state.value = _state.value.copy(
|
||||
progressDialogMessage = -1
|
||||
)
|
||||
_state.update {
|
||||
it.copy(progressDialogMessage = -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,11 +52,120 @@ class LinkDeviceViewModel : ViewModel() {
|
||||
progressDialogMessage = -1
|
||||
)
|
||||
} else {
|
||||
_state.value = _state.value.copy(
|
||||
devices = devices,
|
||||
progressDialogMessage = -1
|
||||
_state.update {
|
||||
it.copy(
|
||||
toastDialog = "",
|
||||
devices = devices,
|
||||
progressDialogMessage = -1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showFrontCamera() {
|
||||
_state.update {
|
||||
val frontCamera = it.showFrontCamera
|
||||
it.copy(
|
||||
showFrontCamera = if (frontCamera == null) true else !frontCamera,
|
||||
pendingBiometrics = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markIntroSheetSeen() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
seenIntroSheet = true,
|
||||
showFrontCamera = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onQrCodeScanned(url: String) {
|
||||
if (_state.value.qrCodeFound || _state.value.qrCodeInvalid) {
|
||||
return
|
||||
}
|
||||
|
||||
val uri = Uri.parse(url)
|
||||
if (LinkDeviceRepository.isValidQr(uri)) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
qrCodeFound = true,
|
||||
qrCodeInvalid = false,
|
||||
url = url,
|
||||
showFrontCamera = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
qrCodeFound = false,
|
||||
qrCodeInvalid = true,
|
||||
url = url,
|
||||
showFrontCamera = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onQrCodeApproved() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
qrCodeFound = false,
|
||||
qrCodeInvalid = false,
|
||||
pendingBiometrics = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onQrCodeDismissed() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
qrCodeFound = false,
|
||||
qrCodeInvalid = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearBiometrics() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
pendingBiometrics = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addDevice() {
|
||||
val uri = Uri.parse(_state.value.url)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val result = LinkDeviceRepository.addDevice(uri)
|
||||
_state.update {
|
||||
it.copy(
|
||||
pendingBiometrics = false,
|
||||
linkDeviceResult = result,
|
||||
url = ""
|
||||
)
|
||||
}
|
||||
LinkedDeviceInactiveCheckJob.enqueue()
|
||||
}
|
||||
}
|
||||
|
||||
fun onLinkDeviceResult(showSheet: Boolean) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
showFinishedSheet = showSheet,
|
||||
linkDeviceResult = LinkDeviceRepository.LinkDeviceResult.UNKNOWN,
|
||||
toastDialog = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markFinishedSheetSeen() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
showFinishedSheet = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
140
app/src/main/java/org/thoughtcrime/securesms/qr/QrScanScreens.kt
Normal file
140
app/src/main/java/org/thoughtcrime/securesms/qr/QrScanScreens.kt
Normal file
@@ -0,0 +1,140 @@
|
||||
package org.thoughtcrime.securesms.qr
|
||||
|
||||
import android.content.Context
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.viewinterop.NoOpUpdate
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.qr.QrScannerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
object QrScanScreens {
|
||||
/**
|
||||
* Full-screen qr scanning screen with permission-asking UI
|
||||
*/
|
||||
@Composable
|
||||
fun QrScanScreen(
|
||||
factory: (Context) -> QrScannerView,
|
||||
update: (QrScannerView) -> Unit = NoOpUpdate,
|
||||
hasPermission: Boolean,
|
||||
onRequestPermissions: () -> Unit = {},
|
||||
qrString: String
|
||||
) {
|
||||
val path = remember { Path() }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, true)
|
||||
) {
|
||||
AndroidView(
|
||||
factory = factory,
|
||||
update = update,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
if (hasPermission) {
|
||||
drawQrCrosshair(path)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!hasPermission) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = qrString,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 24.dp).fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user