From e2961a3f6f7ed3265ab76f46ecb3c691a1dcc80e Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 21 Mar 2025 14:51:27 -0400 Subject: [PATCH] Add auto-fill backup key support. --- .../MessageBackupsKeyVerifyScreen.kt | 32 +++-- .../ui/restore/BackupKeyAutoFill.kt | 118 ++++++++++++++++++ .../ui/restore/EnterBackupKeyScreen.kt | 24 +++- 3 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyAutoFill.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt index 1684e71ddb..c3ad84f053 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyVerifyScreen.kt @@ -32,16 +32,17 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.rememberModalBottomSheetState 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.dimensionResource @@ -63,6 +64,8 @@ import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.horizontalGutters import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.registrationv3.ui.restore.BackupKeyVisualTransformation +import org.thoughtcrime.securesms.registrationv3.ui.restore.attachBackupKeyAutoFillHelper +import org.thoughtcrime.securesms.registrationv3.ui.restore.backupKeyAutoFillHelper import org.whispersystems.signalservice.api.AccountEntropyPool import kotlin.random.Random import kotlin.random.nextInt @@ -71,7 +74,7 @@ import org.signal.core.ui.R as CoreUiR /** * Prompt user to re-enter backup key (AEP) to confirm they have it still. */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun MessageBackupsKeyVerifyScreen( backupKey: String, @@ -119,12 +122,20 @@ fun MessageBackupsKeyVerifyScreen( Spacer(modifier = Modifier.height(48.dp)) + val updateEnteredBackupKey = { input: String -> + enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase() + isBackupKeyValid = enteredBackupKey == backupKey + showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length + } + + var requestFocus: Boolean by remember { mutableStateOf(true) } + val autoFillHelper = backupKeyAutoFillHelper { updateEnteredBackupKey(it) } + TextField( value = enteredBackupKey, onValueChange = { - enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(it).uppercase() - isBackupKeyValid = enteredBackupKey == backupKey - showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length + updateEnteredBackupKey(it) + autoFillHelper.onValueChanged(it) }, label = { Text(text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__backup_key)) @@ -154,13 +165,16 @@ fun MessageBackupsKeyVerifyScreen( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) + .attachBackupKeyAutoFillHelper(autoFillHelper) + .onGloballyPositioned { + if (requestFocus) { + focusRequester.requestFocus() + requestFocus = false + } + } ) } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - Surface( shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp, modifier = Modifier.fillMaxWidth() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyAutoFill.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyAutoFill.kt new file mode 100644 index 0000000000..e8b7c70058 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyAutoFill.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.view.autofill.AutofillManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.content.ContextCompat +import kotlin.math.roundToInt +import android.graphics.Rect as ViewRect +import androidx.compose.ui.geometry.Rect as ComposeRect + +/** + * Provide a compose friendly way to autofill the backup key from a password manager. + */ +@OptIn(ExperimentalComposeUiApi::class) +@SuppressLint("NewApi") +@Composable +fun backupKeyAutoFillHelper( + onFill: (String) -> Unit +): BackupKeyAutoFillHelper { + val view = LocalView.current + val context = LocalContext.current + val autofill: Autofill? = LocalAutofill.current + + val node = remember { AutofillNode(autofillTypes = listOf(AutofillType.Password), onFill = onFill) } + LocalAutofillTree.current += node + + return remember { + object : BackupKeyAutoFillHelper(context) { + override fun request() { + if (node.boundingBox != null) { + autofill?.requestAutofillForNode(node) + } + } + + override fun cancel() { + autofill?.cancelAutofillForNode(node) + } + + /** + * Call when need to manually prompt auto-fill options when text field is empty. For some reason calling + * [request] like we do for on focus changes is not enough. + */ + override fun requestDirectly() { + val bounds = node.boundingBox?.let { ViewRect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt()) } + if (bounds != null) { + autoFillManager?.requestAutofill(view, node.id, bounds) + } + } + + override fun updateNodeBounds(boundsInWindow: ComposeRect) { + node.boundingBox = boundsInWindow + } + } + } +} + +/** + * Attach a [BackupKeyAutoFillHelper] return from [backupKeyAutoFillHelper] to setup the default + * callbacks needed to make requests on the view's behalf. + */ +fun Modifier.attachBackupKeyAutoFillHelper(helper: BackupKeyAutoFillHelper): Modifier { + return this.then( + Modifier + .onFocusChanged { + if (it.isFocused) { + helper.request() + } else { + helper.cancel() + } + } + .onGloballyPositioned { + helper.updateNodeBounds(it.boundsInWindow()) + } + ) +} + +/** + * Weird compose-interop abstract class to let us return something to the caller of [backupKeyAutoFillHelper] + * and capture inner compose data to implement the methods that need various compose provided things. + */ +abstract class BackupKeyAutoFillHelper(context: Context) { + protected val autoFillManager: AutofillManager? = if (Build.VERSION.SDK_INT >= 26) { + ContextCompat.getSystemService(context, AutofillManager::class.java) + } else { + null + } + + fun onValueChanged(value: String) { + if (value.isEmpty()) { + requestDirectly() + } + } + + abstract fun request() + abstract fun cancel() + abstract fun requestDirectly() + abstract fun updateNodeBounds(boundsInWindow: ComposeRect) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyScreen.kt index bf7352c4c8..2f793d496d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyScreen.kt @@ -30,13 +30,16 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.rememberModalBottomSheetState 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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -124,12 +127,18 @@ fun EnterBackupKeyScreen( } ) { val focusRequester = remember { FocusRequester() } + var requestFocus: Boolean by remember { mutableStateOf(true) } val visualTransform = remember(chunkLength) { BackupKeyVisualTransformation(chunkSize = chunkLength) } val keyboardController = LocalSoftwareKeyboardController.current + val autoFillHelper = backupKeyAutoFillHelper { onBackupKeyChanged(it) } + TextField( value = backupKey, - onValueChange = onBackupKeyChanged, + onValueChange = { + onBackupKeyChanged(it) + autoFillHelper.onValueChanged(it) + }, label = { Text(text = stringResource(id = R.string.EnterBackupKey_backup_key)) }, @@ -158,12 +167,15 @@ fun EnterBackupKeyScreen( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) + .attachBackupKeyAutoFillHelper(autoFillHelper) + .onGloballyPositioned { + if (requestFocus) { + focusRequester.requestFocus() + requestFocus = false + } + } ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - if (sheetState.isVisible) { ModalBottomSheet( dragHandle = null,