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
↓ peach-android-45ee189c.tar.gzatlas at this save
files changed3 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 −0app/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+}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 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()