Update add linked devices screen.

This commit is contained in:
Michelle Tang
2024-06-05 18:14:17 -07:00
committed by Alex Hart
parent ac52b5b992
commit d3eb480d31
19 changed files with 1429 additions and 28 deletions

View File

@@ -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()
)
}
}

View File

@@ -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 = {})
}
}

View File

@@ -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

View File

@@ -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 = {})
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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
)

View File

@@ -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
)
}
}
}

View 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())
)
)
}
}