From 05805ddc7f5ae16105ae6b12f207e90e6c1348f4 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Fri, 13 Sep 2024 00:45:40 +0200 Subject: [PATCH] Re-add "Link without scanning" option for device linking Fixes #360 --- .../linkdevice/AddLinkDeviceFragment.kt | 66 ++++--- .../linkdevice/LinkDeviceFragment.kt | 6 +- .../linkdevice/LinkDeviceIntroBottomSheet.kt | 31 +++- .../linkdevice/LinkDeviceManualEntryScreen.kt | 161 ++++++++++++++++++ .../linkdevice/LinkDeviceSettingsState.kt | 2 +- .../linkdevice/LinkDeviceViewModel.kt | 7 +- .../app_settings_with_change_number.xml | 21 +-- 7 files changed, 242 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceManualEntryScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt index e0ef86cc8f..2753f89186 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/AddLinkDeviceFragment.kt @@ -28,7 +28,6 @@ import org.signal.core.ui.SignalPreview 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 @@ -44,19 +43,15 @@ class AddLinkDeviceFragment : ComposeFragment() { 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, + linkWithoutQrCode = state.linkWithoutQrCode, + onLinkDeviceWithUrl = { url -> + viewModel.onQrCodeScanned(url) + viewModel.addDevice() + }, onRequestPermissions = { askPermissions() }, onShowFrontCamera = { viewModel.showFrontCamera() }, onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) }, @@ -91,6 +86,8 @@ private fun MainScreen( state: LinkDeviceSettingsState, navController: NavController? = null, hasPermissions: Boolean = false, + linkWithoutQrCode: Boolean = false, + onLinkDeviceWithUrl: (String) -> Unit = {}, onRequestPermissions: () -> Unit = {}, onShowFrontCamera: () -> Unit = {}, onQrCodeScanned: (String) -> Unit = {}, @@ -101,31 +98,44 @@ private fun MainScreen( onLinkDeviceFailure: () -> Unit = {} ) { Scaffolds.Settings( - title = "", + title = if (linkWithoutQrCode) stringResource(id = R.string.DeviceAddFragment__link_without_scanning) else "", 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) + if (!linkWithoutQrCode) { + 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, - linkDeviceResult = state.linkDeviceResult, - onLinkDeviceSuccess = onLinkDeviceSuccess, - onLinkDeviceFailure = onLinkDeviceFailure, - modifier = Modifier.padding(contentPadding) - ) + if (!linkWithoutQrCode) { + LinkDeviceQrScanScreen( + hasPermission = hasPermissions, + onRequestPermissions = onRequestPermissions, + showFrontCamera = state.showFrontCamera, + qrCodeFound = state.qrCodeFound, + qrCodeInvalid = state.qrCodeInvalid, + onQrCodeScanned = onQrCodeScanned, + onQrCodeAccepted = onQrCodeApproved, + onQrCodeDismissed = onQrCodeDismissed, + onQrCodeRetry = onQrCodeRetry, + linkDeviceResult = state.linkDeviceResult, + onLinkDeviceSuccess = onLinkDeviceSuccess, + onLinkDeviceFailure = onLinkDeviceFailure, + modifier = Modifier.padding(contentPadding) + ) + } else { + LinkDeviceManualEntryScreen( + onLinkDeviceWithUrl = onLinkDeviceWithUrl, + qrCodeFound = state.qrCodeFound, + linkDeviceResult = state.linkDeviceResult, + onLinkDeviceSuccess = onLinkDeviceSuccess, + onLinkDeviceFailure = onLinkDeviceFailure, + modifier = Modifier.padding(contentPadding) + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt index ead6f0a9ca..b91f52eb35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt @@ -93,7 +93,7 @@ class LinkDeviceFragment : ComposeFragment() { biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int -> if (result == BiometricDeviceAuthentication.AUTHENTICATED) { - findNavController().safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment) + findNavController().safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceIntroBottomSheet) } } @@ -148,7 +148,7 @@ class LinkDeviceFragment : ComposeFragment() { if (biometricAuth.canAuthenticate()) { biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.LinkDeviceFragment__unlock_to_link)) } } else { - navController.safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment) + navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceIntroBottomSheet) } }, setDeviceToRemove = { device -> viewModel.setDeviceToRemove(device) }, @@ -171,7 +171,7 @@ class LinkDeviceFragment : ComposeFragment() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { Log.i(TAG, "Authentication succeeded") - findNavController().safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment) + findNavController().safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceIntroBottomSheet) } override fun onAuthenticationFailed() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceIntroBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceIntroBottomSheet.kt index 16584118ce..a660264c2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceIntroBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceIntroBottomSheet.kt @@ -10,11 +10,16 @@ import androidx.compose.material3.MaterialTheme 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.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 androidx.fragment.app.activityViewModels +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants @@ -25,22 +30,32 @@ import org.signal.core.ui.Previews import org.signal.core.ui.SignalPreview import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.util.navigation.safeNavigate /** * Bottom sheet dialog displayed when users click 'Link a device' */ class LinkDeviceIntroBottomSheet : ComposeBottomSheetDialogFragment() { + private val viewModel: LinkDeviceViewModel by activityViewModels() + override val peekHeightPercentage: Float = 0.8f @Composable override fun SheetContent() { - EducationSheet(this::dismissAllowingStateLoss) + val navController: NavController by remember { mutableStateOf(findNavController()) } + + EducationSheet( + onClick = { shouldScanQrCode -> + viewModel.requestLinkWithoutQrCode(!shouldScanQrCode) + navController.safeNavigate(R.id.action_linkDeviceIntroBottomSheet_to_addLinkDeviceFragment) + } + ) } } @Composable -fun EducationSheet(onClick: () -> Unit) { +fun EducationSheet(onClick: (Boolean) -> Unit) { val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.linking_device)) return Column( @@ -54,7 +69,7 @@ fun EducationSheet(onClick: () -> Unit) { LottieAnimation(composition, iterations = LottieConstants.IterateForever, modifier = Modifier.matchParentSize()) } Text( - text = stringResource(R.string.AddLinkDeviceFragment__scan_qr_code), + text = stringResource(R.string.LinkDeviceFragment__link_a_new_device), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(bottom = 12.dp) ) @@ -65,10 +80,16 @@ fun EducationSheet(onClick: () -> Unit) { modifier = Modifier.padding(bottom = 12.dp) ) Buttons.LargeTonal( - onClick = onClick, + onClick = { onClick(true) }, + modifier = Modifier.defaultMinSize(minWidth = 220.dp) + ) { + Text(stringResource(id = R.string.AddLinkDeviceFragment__scan_qr_code)) + } + Buttons.Small( + onClick = { onClick(false) }, modifier = Modifier.defaultMinSize(minWidth = 220.dp) ) { - Text(stringResource(id = R.string.AddLinkDeviceFragment__okay)) + Text(stringResource(id = R.string.DeviceAddFragment__link_without_scanning)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceManualEntryScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceManualEntryScreen.kt new file mode 100644 index 0000000000..7b290dd4c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceManualEntryScreen.kt @@ -0,0 +1,161 @@ +package org.thoughtcrime.securesms.linkdevice + +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +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.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.thoughtcrime.securesms.R + +@Composable +fun LinkDeviceManualEntryScreen( + onLinkDeviceWithUrl: (String) -> Unit, + qrCodeFound: Boolean, + linkDeviceResult: LinkDeviceRepository.LinkDeviceResult, + onLinkDeviceSuccess: () -> Unit, + onLinkDeviceFailure: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + 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 + } + } + + var qrLink by remember { mutableStateOf("") } + val isQrLinkValid = LinkDeviceRepository.isValidQr(Uri.parse(qrLink)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(horizontal = 24.dp) + ) { + item { + Text( + text = stringResource(id = R.string.enter_device_link_dialog__if_your_phone_cant_scan_the_qr_code_you_can_manually_enter_the_link_encoded_in_the_qr_code), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.padding(vertical = 12.dp) + ) + } + + item { + val textFieldStyle = MaterialTheme.typography.bodyLarge.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + TextField( + value = qrLink, + onValueChange = { qrLink = it }, + isError = !isQrLinkValid, + placeholder = { + Text( + text = stringResource(id = R.string.enter_device_link_dialog__url), + style = textFieldStyle + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done + ), + textStyle = textFieldStyle, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .defaultMinSize(minHeight = 40.dp) + ) + } + + item { + Text( + text = stringResource(id = R.string.AddLinkDeviceFragment__this_device_will_see_your_groups_contacts), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.padding(vertical = 12.dp) + ) + } + } + + Row { + if (qrCodeFound) { + CircularProgressIndicator( + modifier = Modifier + .padding(vertical = 16.dp) + ) + } else { + Buttons.LargeTonal( + enabled = isQrLinkValid, + onClick = { onLinkDeviceWithUrl(qrLink) }, + modifier = Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(vertical = 16.dp) + ) { + Text(text = stringResource(id = R.string.device_list_fragment__link_new_device)) + } + } + } + } +} + +private fun makeToast(context: Context, messageId: Int, onLinkDeviceFailure: () -> Unit) { + Toast.makeText(context, messageId, Toast.LENGTH_LONG).show() + onLinkDeviceFailure() +} + +@SignalPreview +@Composable +private fun LinkDeviceManualEntryScreenPreview() { + Previews.Preview { + LinkDeviceManualEntryScreen( + onLinkDeviceWithUrl = {}, + qrCodeFound = false, + linkDeviceResult = LinkDeviceRepository.LinkDeviceResult.SUCCESS, + onLinkDeviceSuccess = {}, + onLinkDeviceFailure = {}, + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt index a6168fe67b..5ec7aba771 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt @@ -16,6 +16,6 @@ data class LinkDeviceSettingsState( val url: String = "", val linkDeviceResult: LinkDeviceRepository.LinkDeviceResult = LinkDeviceRepository.LinkDeviceResult.UNKNOWN, val showFinishedSheet: Boolean = false, - val seenIntroSheet: Boolean = false, + val linkWithoutQrCode: Boolean = false, val pendingNewDevice: Boolean = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt index 2797815860..08f159fe47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt @@ -104,12 +104,9 @@ class LinkDeviceViewModel : ViewModel() { } } - fun markIntroSheetSeen() { + fun requestLinkWithoutQrCode(value: Boolean) { _state.update { - it.copy( - seenIntroSheet = true, - showFrontCamera = null - ) + it.copy(linkWithoutQrCode = value) } } diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index a687b316ef..797d1d8ef5 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -244,12 +244,8 @@ android:name="org.thoughtcrime.securesms.linkdevice.LinkDeviceFragment" android:label="link_device_fragment"> + android:id="@+id/action_linkDeviceFragment_to_linkDeviceIntroBottomSheet" + app:destination="@id/linkDeviceIntroBottomSheet" /> @@ -268,13 +264,18 @@ android:id="@+id/addLinkDeviceFragment" android:name="org.thoughtcrime.securesms.linkdevice.AddLinkDeviceFragment" android:label="link_device_add_fragment"> - + android:name="org.thoughtcrime.securesms.linkdevice.LinkDeviceIntroBottomSheet"> + +