45ee189c2e7e

unlock inline in the autofill sheet with biometrics, offer to enable Peach as the autofill service

time
2026-07-24 23:53 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
autofill inline unlock
parent
8ee5d862e957
3 files app/src/main/java/com/peachpasswords/autofill/AutofillEnablement.kt+54 −0app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+164 −33app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+18 −0
app/src/main/java/com/peachpasswords/autofill/AutofillEnablement.kt+54 −0
@@ -0,0 +1,54 @@1+package com.peachpasswords.autofill2+3+import android.content.Context4+import android.content.Intent5+import android.net.Uri6+import android.provider.Settings7+import android.view.autofill.AutofillManager8+9+/**10+ * Whether Peach is actually the system's autofill service, and the one-time11+ * offer to make it so.12+ *13+ * Nothing ever asked. Android does not prompt on install, so a user who never14+ * goes looking in system settings has a password manager that appears to do15+ * nothing on other apps' login screens — which reads as "autofill is broken",16+ * not "autofill is off". June had to find the setting manually.17+ *18+ * Asked once and then never again: the system dialog is a full-screen list of19+ * every autofill provider, and re-showing it to someone who deliberately uses a20+ * different manager would be nagging.21+ */22+object AutofillEnablement {23+24+    private const val PREFS = "peach_autofill"25+    private const val ASKED_KEY = "asked_enable"26+27+    fun isSupported(context: Context): Boolean =28+        context.getSystemService(AutofillManager::class.java)?.isAutofillSupported == true29+30+    fun isEnabled(context: Context): Boolean =31+        context.getSystemService(AutofillManager::class.java)?.hasEnabledAutofillServices() == true32+33+    fun shouldOffer(context: Context): Boolean =34+        isSupported(context) && !isEnabled(context) && !asked(context)35+36+    private fun asked(context: Context) =37+        context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(ASKED_KEY, false)38+39+    fun markAsked(context: Context) {40+        context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)41+            .edit().putBoolean(ASKED_KEY, true).apply()42+    }43+44+    /**45+     * The system picker, pre-selecting Peach. Callers must handle this failing46+     * to resolve — some OEM builds ship no handler for it, in which case the47+     * only route is the general settings screen ([fallbackIntent]).48+     */49+    fun requestIntent(context: Context): Intent =50+        Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE)51+            .setData(Uri.parse("package:${context.packageName}"))52+53+    fun fallbackIntent(): Intent = Intent(Settings.ACTION_SETTINGS)54+}

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+164 −33
@@ -21,9 +21,21 @@2121 import androidx.compose.material3.ModalBottomSheet2222 import androidx.compose.material3.Text2323 import androidx.compose.material3.rememberModalBottomSheetState24+import androidx.compose.foundation.text.BasicTextField25+import androidx.compose.foundation.text.KeyboardActions26+import androidx.compose.foundation.text.KeyboardOptions2427 import androidx.compose.runtime.Composable28+import androidx.compose.runtime.LaunchedEffect2529 import androidx.compose.runtime.getValue30+import androidx.compose.runtime.mutableStateOf2631 import androidx.compose.runtime.remember32+import androidx.compose.runtime.rememberCoroutineScope33+import androidx.compose.runtime.setValue34+import androidx.compose.ui.graphics.SolidColor35+import androidx.compose.ui.text.TextStyle36+import androidx.compose.ui.text.input.ImeAction37+import androidx.compose.ui.text.input.KeyboardType38+import androidx.compose.ui.text.input.PasswordVisualTransformation2739 import androidx.compose.ui.Alignment2840 import androidx.compose.ui.Modifier2941 import androidx.compose.ui.graphics.Color@@ -32,8 +44,10 @@3244 import androidx.compose.ui.unit.dp3345 import androidx.compose.ui.unit.sp3446 import androidx.fragment.app.FragmentActivity35-import com.peachpasswords.MainActivity47+import androidx.lifecycle.lifecycleScope3648 import com.peachpasswords.shared.extractDomain49+import com.peachpasswords.ui.lock.BiometricKeyStore50+import com.peachpasswords.ui.lock.Biometrics3751 import com.peachpasswords.ui.components.FaviconInitials3852 import com.peachpasswords.ui.theme.ACCENT_COLORS3953 import com.peachpasswords.ui.theme.DEFAULT_ACCENT@@ -41,16 +55,27 @@4155 import com.peachpasswords.ui.theme.peachColors4256 import com.peachpasswords.ui.theme.PeachTheme4357 import com.peachpasswords.ui.theme.PeachType58+import com.peachpasswords.vault.UnlockResult4459 import com.peachpasswords.vault.VaultEntry4560 import com.peachpasswords.vault.VaultRepository4661 import dagger.hilt.android.AndroidEntryPoint62+import kotlinx.coroutines.launch4763 import javax.inject.Inject4864 4965 /**5066  * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"5167  * in the system autofill drop-down. Lists matching vault entries using the same52- * row vocabulary as the vault list; picking one returns a filled [android.service.autofill.Dataset]53- * to the framework via [AutofillManager.EXTRA_AUTHENTICATION_RESULT].68+ * row vocabulary as the vault list; picking one returns a filled69+ * [android.service.autofill.Dataset] to the framework via70+ * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].71+ *72+ * Unlocking happens HERE, in the sheet. It used to send the user to73+ * MainActivity and return RESULT_CANCELED, which produced a loop with no exit:74+ * the framework treats a cancelled auth as "not filled" and re-offers the same75+ * prompt, so the user unlocked the app, came back to a field that still asked76+ * them to unlock, and had no way to tell that anything had happened. Autofill77+ * is a modal interruption of someone else's app — leaving it to go somewhere78+ * else is the one thing it must not do.5479  */5580 @AndroidEntryPoint5681 class AutofillEntryPickerActivity : FragmentActivity() {@@ -70,17 +95,32 @@7095             webDomain = origin.takeIf { isWebOrigin },7196             packageName = origin.takeUnless { isWebOrigin },7297         )73-        val unlocked = vault.isUnlocked.value74-        val entries = if (unlocked) {75-            autofillPickerEntries(vault.getEntries(), parsed, MAX_ROWS)76-        } else {77-            emptyList()78-        }7998         val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT8099 81100         setContent {82101             PeachTheme(accent = accent) {83102                 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)103+                val scope = rememberCoroutineScope()104+                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }105+                var error by remember { mutableStateOf<String?>(null) }106+                var busy by remember { mutableStateOf(false) }107+108+                val entries = remember(unlocked) {109+                    if (unlocked) autofillPickerEntries(vault.getEntries(), parsed, MAX_ROWS) else emptyList()110+                }111+112+                // Offer biometrics immediately — it is the whole point of113+                // unlocking here rather than in the app, and a prompt the user114+                // can dismiss straight onto the password field costs nothing.115+                LaunchedEffect(unlocked) {116+                    if (!unlocked && canBiometric()) {117+                        tryBiometric(118+                            onUnlocked = { unlocked = true },119+                            onFail = { },120+                        )121+                    }122+                }123+84124                 ModalBottomSheet(85125                     onDismissRequest = { cancel() },86126                     sheetState = sheetState,@@ -91,14 +131,50 @@91131                         origin = parsed.origin,92132                         unlocked = unlocked,93133                         entries = entries,134+                        error = error,135+                        busy = busy,136+                        canBiometric = canBiometric(),137+                        onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },138+                        onUnlock = { password ->139+                            busy = true140+                            error = null141+                            scope.launch {142+                                when (vault.unlock(password)) {143+                                    is UnlockResult.Success -> unlocked = true144+                                    UnlockResult.WrongPassword -> error = "Wrong master password."145+                                    else -> error = "Couldn't unlock."146+                                }147+                                busy = false148+                            }149+                        },94150                         onPick = { returnDataset(parsed, it) },95-                        onOpenApp = { openApp() },96151                     )97152                 }98153             }99154         }100155     }101156 157+    private fun canBiometric(): Boolean =158+        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)159+160+    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {161+        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()162+        Biometrics.authenticateCrypto(163+            activity = this,164+            title = "Unlock Peach",165+            subtitle = "Fill your saved details",166+            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),167+            onSuccess = { result ->168+                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()169+                val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()170+                lifecycleScope.launch {171+                    if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()172+                }173+            },174+            onFail = onFail,175+        )176+    }177+102178     private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {103179         val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)104180         if (dataset == null) { cancel(); return }@@ -107,11 +183,6 @@107183         finish()108184     }109185 110-    private fun openApp() {111-        startActivity(Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))112-        cancel()113-    }114-115186     private fun cancel() {116187         setResult(RESULT_CANCELED)117188         finish()@@ -160,8 +231,12 @@160231     origin: String?,161232     unlocked: Boolean,162233     entries: List<VaultEntry>,234+    error: String?,235+    busy: Boolean,236+    canBiometric: Boolean,237+    onBiometric: () -> Unit,238+    onUnlock: (String) -> Unit,163239     onPick: (VaultEntry) -> Unit,164-    onOpenApp: () -> Unit,165240 ) {166241     val accent = LocalPeachAccent.current167242     Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {@@ -181,23 +256,7 @@181256         )182257 183258         if (!unlocked) {184-            Text(185-                text = "Peach is locked. Open the app to unlock, then return to this field.",186-                color = peachColors.T1,187-                fontFamily = PeachType.Body,188-                fontSize = 14.sp,189-                modifier = Modifier.padding(bottom = 16.dp),190-            )191-            Text(192-                text = "Open Peach",193-                color = accent,194-                fontFamily = PeachType.Mono,195-                fontSize = 13.sp,196-                modifier = Modifier197-                    .fillMaxWidth()198-                    .clickable(onClick = onOpenApp)199-                    .padding(vertical = 12.dp),200-            )259+            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)201260         } else if (entries.isEmpty()) {202261             Text(203262                 text = "No saved entries match this site.",@@ -212,6 +271,78 @@212271                 }213272             }214273         }274+    }275+}276+277+@Composable278+private fun UnlockBody(279+    error: String?,280+    busy: Boolean,281+    canBiometric: Boolean,282+    onBiometric: () -> Unit,283+    onUnlock: (String) -> Unit,284+) {285+    val accent = LocalPeachAccent.current286+    var password by remember { mutableStateOf("") }287+288+    Text(289+        text = "Unlock to fill",290+        color = peachColors.T0,291+        fontFamily = PeachType.Body,292+        fontSize = 15.sp,293+        modifier = Modifier.padding(bottom = 10.dp),294+    )295+    BasicTextField(296+        value = password,297+        onValueChange = { password = it },298+        singleLine = true,299+        enabled = !busy,300+        textStyle = TextStyle(301+            color = peachColors.T0,302+            fontFamily = PeachType.Mono,303+            fontSize = 16.sp,304+        ),305+        cursorBrush = SolidColor(accent),306+        visualTransformation = PasswordVisualTransformation(),307+        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),308+        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),309+        modifier = Modifier310+            .fillMaxWidth()311+            .background(peachColors.S2, RoundedCornerShape(8.dp))312+            .padding(horizontal = 12.dp, vertical = 12.dp),313+    )314+    if (error != null) {315+        Text(316+            text = error,317+            color = peachColors.Breach,318+            fontFamily = PeachType.Body,319+            fontSize = 12.sp,320+            modifier = Modifier.padding(top = 8.dp),321+        )322+    }323+    Row(324+        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),325+        horizontalArrangement = Arrangement.spacedBy(20.dp),326+        verticalAlignment = Alignment.CenterVertically,327+    ) {328+        if (canBiometric) {329+            Text(330+                text = "Use biometrics",331+                color = accent,332+                fontFamily = PeachType.Mono,333+                fontSize = 13.sp,334+                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),335+            )336+        }337+        Text(338+            text = if (busy) "Unlocking…" else "Unlock",339+            color = if (password.isEmpty() || busy) peachColors.T2 else accent,340+            fontFamily = PeachType.Mono,341+            fontSize = 13.sp,342+            modifier = Modifier343+                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }344+                .padding(vertical = 8.dp),345+        )215346     }216347 }217348 

view file

app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+18 −0
@@ -94,6 +94,7 @@9494 import com.peachpasswords.sync.syncAlertMessage9595 import com.peachpasswords.sync.SyncStatus9696 import com.peachpasswords.ui.sync.SyncViewModel97+import com.peachpasswords.autofill.AutofillEnablement9798 import com.peachpasswords.ui.PeachNav9899 import com.peachpasswords.ui.components.PeachEmptyOrEndState99100 import com.peachpasswords.ui.components.PeachFilterPills@@ -125,6 +126,23 @@125126 ) {126127     LaunchedEffect(Unit) {127128         if (startNoteOnly && !vm.noteOnly.value) vm.toggleNoteFilter()129+    }130+131+    // Android never prompts to pick an autofill service, so a user who doesn't132+    // go hunting in system settings gets a password manager that silently does133+    // nothing on other apps' login screens. Offered once, on the first look at134+    // an unlocked vault — the point where autofill is obviously the next thing135+    // they'd want.136+    val autofillContext = LocalContext.current137+    LaunchedEffect(Unit) {138+        if (AutofillEnablement.shouldOffer(autofillContext)) {139+            AutofillEnablement.markAsked(autofillContext)140+            runCatching { autofillContext.startActivity(AutofillEnablement.requestIntent(autofillContext)) }141+                .onFailure {142+                    // Some OEM builds ship no handler for the direct picker.143+                    runCatching { autofillContext.startActivity(AutofillEnablement.fallbackIntent()) }144+                }145+        }128146     }129147     val entries by vm.entries.collectAsState()130148     val sort by vm.sort.collectAsState()

view file