Generalize device authentication education sheet for backups.

This commit is contained in:
Michelle Tang
2025-07-18 13:03:49 -04:00
committed by GitHub
parent b188c45cd9
commit eae0b43858
11 changed files with 104 additions and 88 deletions

View File

@@ -1,96 +0,0 @@
package org.thoughtcrime.securesms.linkdevice
import android.content.DialogInterface
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.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 androidx.fragment.app.activityViewModels
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
/**
* Education sheet shown before biometrics when linking a device
*/
class LinkDeviceAuthEducationSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.67f
private val viewModel: LinkDeviceViewModel by activityViewModels()
@Composable
override fun SheetContent() {
DeviceEducationSheet(this::onDismiss)
}
override fun onCancel(dialog: DialogInterface) {
viewModel.markBioAuthEducationSheetSeen(true)
super.onCancel(dialog)
}
fun onDismiss() {
viewModel.markBioAuthEducationSheetSeen(true)
dismissAllowingStateLoss()
}
}
@Composable
private fun DeviceEducationSheet(onClick: () -> Unit) {
return Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
Icon(
painter = painterResource(R.drawable.ic_phone_lock),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.padding(top = 24.dp)
)
Text(
text = stringResource(R.string.LinkDeviceFragment__before_linking),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 20.dp, bottom = 8.dp),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.LinkDeviceFragment__tap_continue_and_enter_phone),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 44.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Buttons.LargeTonal(
onClick = onClick,
modifier = Modifier.defaultMinSize(minWidth = 220.dp).padding(top = 28.dp, bottom = 56.dp)
) {
Text(stringResource(id = R.string.LinkDeviceFragment__continue))
}
}
}
@SignalPreview
@Composable
fun DeviceEducationSheetPreview() {
Previews.BottomSheetPreview {
DeviceEducationSheet(onClick = {})
}
}

View File

@@ -68,6 +68,7 @@ import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.DevicePinAuthEducationSheet
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.DialogState
@@ -161,7 +162,7 @@ class LinkDeviceFragment : ComposeFragment() {
Toast.makeText(context, context.getString(R.string.DeviceListActivity_network_failed), Toast.LENGTH_LONG).show()
}
LinkDeviceSettingsState.OneTimeEvent.LaunchQrCodeScanner -> {
navController.navigateToQrScannerIfAuthed(state.seenBioAuthEducationSheet)
navController.navigateToQrScannerIfAuthed()
}
LinkDeviceSettingsState.OneTimeEvent.ShowFinishedSheet -> {
navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceFinishedSheet)
@@ -185,15 +186,6 @@ class LinkDeviceFragment : ComposeFragment() {
}
}
LaunchedEffect(state.seenBioAuthEducationSheet) {
if (state.seenBioAuthEducationSheet) {
if (!biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.LinkDeviceFragment__unlock_to_link)) }) {
navController.safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment)
}
viewModel.markBioAuthEducationSheetSeen(false)
}
}
Scaffolds.Settings(
title = stringResource(id = R.string.preferences__linked_devices),
onNavigationClick = { navController.popOrFinish() },
@@ -206,7 +198,7 @@ class LinkDeviceFragment : ComposeFragment() {
onLearnMoreClicked = { navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceLearnMoreBottomSheet) },
onLinkNewDeviceClicked = {
viewModel.stopExistingPolling()
navController.navigateToQrScannerIfAuthed(!state.needsBioAuthEducationSheet)
navController.navigateToQrScannerIfAuthed()
},
onDeviceSelectedForRemoval = { device -> viewModel.setDeviceToRemove(device) },
onDeviceRemovalConfirmed = { device -> viewModel.removeDevice(device) },
@@ -236,14 +228,15 @@ class LinkDeviceFragment : ComposeFragment() {
return SupportEmailUtil.generateSupportEmailBody(requireContext(), filter, prefix.toString(), null)
}
private fun NavController.navigateToQrScannerIfAuthed(seenEducation: Boolean) {
if (seenEducation && biometricAuth.canAuthenticate(requireContext())) {
if (!biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.LinkDeviceFragment__unlock_to_link)) }) {
this.safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment)
private fun NavController.navigateToQrScannerIfAuthed() {
if (biometricAuth.shouldShowEducationSheet(requireContext())) {
DevicePinAuthEducationSheet.show(getString(R.string.LinkDeviceFragment__before_linking), parentFragmentManager)
parentFragmentManager.setFragmentResultListener(DevicePinAuthEducationSheet.REQUEST_KEY, viewLifecycleOwner) { _, _ ->
if (!biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.LinkDeviceFragment__unlock_to_link)) }) {
this.safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment)
}
}
} else if (biometricAuth.canAuthenticate(requireContext())) {
this.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceEducationSheet)
} else {
} else if (!biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.LinkDeviceFragment__unlock_to_link)) }) {
this.safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment)
}
}
@@ -609,8 +602,7 @@ private fun DeviceListScreenPreview() {
Device(1, "Sam's Macbook Pro", 1715793982000, 1716053182000),
Device(1, "Sam's iPad", 1715793182000, 1716053122000)
),
seenQrEducationSheet = true,
seenBioAuthEducationSheet = true
seenQrEducationSheet = true
)
)
}
@@ -623,8 +615,7 @@ private fun DeviceListScreenLoadingPreview() {
DeviceListScreen(
state = LinkDeviceSettingsState(
deviceListLoading = true,
seenQrEducationSheet = true,
seenBioAuthEducationSheet = true
seenQrEducationSheet = true
)
)
}
@@ -637,8 +628,7 @@ private fun DeviceListScreenLinkingPreview() {
DeviceListScreen(
state = LinkDeviceSettingsState(
dialogState = DialogState.Linking,
seenQrEducationSheet = true,
seenBioAuthEducationSheet = true
seenQrEducationSheet = true
)
)
}
@@ -651,8 +641,7 @@ private fun DeviceListScreenUnlinkingPreview() {
DeviceListScreen(
state = LinkDeviceSettingsState(
dialogState = DialogState.Unlinking,
seenQrEducationSheet = true,
seenBioAuthEducationSheet = true
seenQrEducationSheet = true
)
)
}
@@ -665,8 +654,7 @@ private fun DeviceListScreenSyncingMessagesPreview() {
DeviceListScreen(
state = LinkDeviceSettingsState(
dialogState = DialogState.SyncingMessages(1, 1),
seenQrEducationSheet = true,
seenBioAuthEducationSheet = true
seenQrEducationSheet = true
)
)
}
@@ -679,8 +667,7 @@ private fun DeviceListScreenSyncingFailedRetryPreview() {
DeviceListScreen(
state = LinkDeviceSettingsState(
dialogState = DialogState.SyncingTimedOut,
seenQrEducationSheet = true,
seenBioAuthEducationSheet = true
seenQrEducationSheet = true
)
)
}
@@ -697,8 +684,7 @@ private fun DeviceListScreenSyncingFailedPreview() {
deviceCreatedAt = 1,
syncFailType = LinkDeviceSettingsState.SyncFailType.NOT_RETRYABLE
),
seenQrEducationSheet = true,
seenBioAuthEducationSheet = true
seenQrEducationSheet = true
)
)
}
@@ -711,8 +697,7 @@ private fun DeviceListScreenContactSupportPreview() {
DeviceListScreen(
state = LinkDeviceSettingsState(
dialogState = DialogState.ContactSupport,
seenQrEducationSheet = true,
seenBioAuthEducationSheet = true
seenQrEducationSheet = true
)
)
}
@@ -725,7 +710,6 @@ private fun DeviceListScreenDeviceUnlinkedPreview() {
DeviceListScreen(
state = LinkDeviceSettingsState(
dialogState = DialogState.DeviceUnlinked(1736454440342),
seenBioAuthEducationSheet = true,
seenQrEducationSheet = true
)
)
@@ -743,8 +727,7 @@ private fun DeviceListScreenNotEnoughStoragePreview() {
deviceCreatedAt = 1,
syncFailType = LinkDeviceSettingsState.SyncFailType.NOT_ENOUGH_SPACE
),
seenQrEducationSheet = true,
seenBioAuthEducationSheet = true
seenQrEducationSheet = true
)
)
}

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.linkdevice
import android.net.Uri
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult
import kotlin.time.Duration.Companion.days
/**
* Information about linked devices. Used in [LinkDeviceViewModel].
@@ -19,8 +18,6 @@ data class LinkDeviceSettingsState(
val linkUri: Uri? = null,
val linkDeviceResult: LinkDeviceResult = LinkDeviceResult.None,
val seenQrEducationSheet: Boolean = SignalStore.uiHints.hasSeenLinkDeviceQrEducationSheet() || SignalStore.account.hasLinkedDevices,
val seenBioAuthEducationSheet: Boolean = false,
val needsBioAuthEducationSheet: Boolean = !seenBioAuthEducationSheet && SignalStore.uiHints.lastSeenLinkDeviceAuthSheetTime < System.currentTimeMillis() - 30.days.inWholeMilliseconds,
val bottomSheetVisible: Boolean = false,
val deviceToEdit: Device? = null,
val shouldCancelArchiveUpload: Boolean = false,

View File

@@ -261,16 +261,6 @@ class LinkDeviceViewModel : ViewModel() {
}
}
fun markBioAuthEducationSheetSeen(seen: Boolean) {
SignalStore.uiHints.lastSeenLinkDeviceAuthSheetTime = System.currentTimeMillis()
_state.update {
it.copy(
seenBioAuthEducationSheet = seen,
needsBioAuthEducationSheet = false
)
}
}
private fun addDeviceWithSync(linkUri: Uri) {
Log.d(TAG, "[addDeviceWithSync] Beginning device adding process.")