AutofillEntryPickerActivity.kt

23.1 KB · raw

1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Bundle5import android.view.autofill.AutofillId6import android.view.autofill.AutofillManager7import androidx.activity.compose.setContent8import androidx.compose.foundation.background9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.Row14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.layout.size17import androidx.compose.foundation.lazy.LazyColumn18import androidx.compose.foundation.lazy.items19import androidx.compose.foundation.shape.RoundedCornerShape20import androidx.compose.material3.ExperimentalMaterial3Api21import androidx.compose.material3.ModalBottomSheet22import androidx.compose.material3.Text23import androidx.compose.material3.rememberModalBottomSheetState24import androidx.compose.foundation.text.BasicTextField25import androidx.compose.foundation.text.KeyboardActions26import androidx.compose.foundation.text.KeyboardOptions27import androidx.compose.runtime.Composable28import androidx.compose.runtime.LaunchedEffect29import androidx.compose.runtime.getValue30import androidx.compose.runtime.mutableStateOf31import androidx.compose.runtime.remember32import androidx.compose.runtime.rememberCoroutineScope33import androidx.compose.runtime.setValue34import androidx.compose.ui.graphics.SolidColor35import androidx.compose.ui.text.TextStyle36import androidx.compose.ui.text.input.ImeAction37import androidx.compose.ui.text.input.KeyboardType38import androidx.compose.ui.text.input.PasswordVisualTransformation39import androidx.compose.ui.Alignment40import androidx.compose.ui.Modifier41import androidx.compose.ui.text.font.FontStyle42import androidx.compose.ui.text.style.TextOverflow43import androidx.compose.ui.unit.dp44import androidx.compose.ui.unit.sp45import androidx.fragment.app.FragmentActivity46import androidx.lifecycle.lifecycleScope47import com.peachpasswords.MainActivity48import com.peachpasswords.shared.extractDomain49import com.peachpasswords.ui.lock.BiometricKeyStore50import com.peachpasswords.ui.lock.Biometrics51import com.peachpasswords.ui.components.FaviconInitials52import com.peachpasswords.ui.theme.ACCENT_COLORS53import com.peachpasswords.ui.theme.DEFAULT_ACCENT54import com.peachpasswords.ui.theme.LocalPeachAccent55import com.peachpasswords.ui.theme.peachColors56import com.peachpasswords.ui.theme.PeachTheme57import com.peachpasswords.ui.theme.PeachType58import com.peachpasswords.vault.UnlockResult59import com.peachpasswords.vault.VaultEntry60import com.peachpasswords.vault.VaultRepository61import dagger.hilt.android.AndroidEntryPoint62import kotlinx.coroutines.delay63import kotlinx.coroutines.launch64import javax.inject.Inject6566/**67 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"68 * in the system autofill drop-down. Lists matching vault entries using the same69 * row vocabulary as the vault list; picking one returns a filled70 * [android.service.autofill.Dataset] to the framework via71 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].72 *73 * Inline-mirror of the browser's selector: always-on search field, vault-wide74 * match against title/username/email/url, and a "+ Create new entry" affordance75 * in the header. The affordance cancels this activity and launches [MainActivity]76 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had77 * typed on the host page.78 *79 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity80 * and return RESULT_CANCELED, which produced a loop with no exit: the framework81 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the82 * user unlocked the app, came back to a field that still asked them to unlock,83 * and had no way to tell that anything had happened. Autofill is a modal84 * interruption of someone else's app — leaving it to go somewhere else is the85 * one thing it must not do.86 */87@AndroidEntryPoint88class AutofillEntryPickerActivity : FragmentActivity() {8990    @Inject lateinit var vault: VaultRepository9192    @OptIn(ExperimentalMaterial3Api::class)93    override fun onCreate(savedInstanceState: Bundle?) {94        super.onCreate(savedInstanceState)9596        val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)97        val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)98        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)99        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)100        val parsed = ParsedStructure(101            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),102            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),103            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),104            webDomain = origin.takeIf { isWebOrigin },105            packageName = origin.takeUnless { isWebOrigin },106        )107        val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT108109        setContent {110            PeachTheme(accent = accent) {111                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)112                val scope = rememberCoroutineScope()113                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }114                var error by remember { mutableStateOf<String?>(null) }115                var busy by remember { mutableStateOf(false) }116117                // The picker filters across the entire vault when the user types118                // a search, so the unlocked entry list is the universe of choices —119                // not just the matched ones. The header still surfaces the matched120                // count separately so users know what the search is broadening.121                val allEntries = remember(unlocked) {122                    if (unlocked) vault.getEntries() else emptyList()123                }124                val matchedEntries = remember(unlocked, allEntries) {125                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()126                }127128                // Offer biometrics immediately — it is the whole point of129                // unlocking here rather than in the app, and a prompt the user130                // can dismiss straight onto the password field costs nothing.131                LaunchedEffect(unlocked) {132                    if (!unlocked && canBiometric()) {133                        tryBiometric(134                            onUnlocked = { unlocked = true },135                            onFail = { },136                        )137                    }138                }139140                ModalBottomSheet(141                    onDismissRequest = { cancel() },142                    sheetState = sheetState,143                    containerColor = peachColors.S1,144                    dragHandle = { AccentDragHandle() },145                ) {146                    PickerBody(147                        origin = parsed.origin,148                        typedIdentity = typedUsername ?: typedEmail,149                        unlocked = unlocked,150                        matchedEntries = matchedEntries,151                        allEntries = allEntries,152                        error = error,153                        busy = busy,154                        canBiometric = canBiometric(),155                        onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },156                        onUnlock = { password ->157                            busy = true158                            error = null159                            scope.launch {160                                when (vault.unlock(password)) {161                                    is UnlockResult.Success -> unlocked = true162                                    UnlockResult.WrongPassword -> error = "Wrong master password."163                                    else -> error = "Couldn't unlock."164                                }165                                busy = false166                            }167                        },168                        onPick = { returnDataset(parsed, it) },169                        onCreateNew = { launchNewEntry(parsed, typedUsername, typedEmail) },170                    )171                }172            }173        }174    }175176    private fun canBiometric(): Boolean =177        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)178179    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {180        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()181        Biometrics.authenticateCrypto(182            activity = this,183            title = "Unlock Peach",184            subtitle = "Fill your saved details",185            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),186            onSuccess = { result ->187                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()188                // M-05: unlockWithKey copies `key` into the session's derivedKey,189                // so the caller-owned reference is now redundant. Zero it whether190                // the unlock succeeds or fails so the unwrapped biometric key191                // doesn't outlive the auth prompt.192                val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()193                lifecycleScope.launch {194                    try {195                        if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()196                    } finally {197                        com.peachpasswords.vault.VaultCrypto.zero(key)198                    }199                }200            },201            onFail = onFail,202        )203    }204205    private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {206        val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)207        if (dataset == null) { cancel(); return }208        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)209        setResult(RESULT_OK, reply)210        finish()211    }212213    /**214     * Cancel the picker and fire MainActivity on the new-entry path. The picker215     * returns CANCELED on purpose — the user is choosing to leave the autofill216     * prompt, not to fill it — and the launcher extra carries the host's typed217     * identity and origin so the new-entry editor opens pre-filled.218     */219    private fun launchNewEntry(parsed: ParsedStructure, typedUsername: String?, typedEmail: String?) {220        val typedIdentity = typedUsername ?: typedEmail221        val intent = Intent(this, MainActivity::class.java).apply {222            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)223            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, parsed.origin)224            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)225            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)226            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)227        }228        startActivity(intent)229        cancel()230    }231232    private fun cancel() {233        setResult(RESULT_CANCELED)234        finish()235    }236237    @Suppress("DEPRECATION")238    private fun intentAutofillId(key: String): AutofillId? =239        if (android.os.Build.VERSION.SDK_INT >= 33) {240            intent.getParcelableExtra(key, AutofillId::class.java)241        } else {242            intent.getParcelableExtra(key)243        }244245    private companion object {246        const val MAX_ROWS = 12247    }248}249250internal fun autofillPickerEntries(251    entries: List<VaultEntry>,252    parsed: ParsedStructure,253    limit: Int,254): List<VaultEntry> = when {255    parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit)256    parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit)257    else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)258}259260/**261 * Case-insensitive substring match across the four user-facing identity columns262 * the browser uses (title, username, email, url). Exposed at file scope so the263 * picker can call it directly and the test suite can exercise the same code.264 */265internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {266    if (query.isEmpty()) return true267    val needle = query.lowercase()268    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)269        .any { it.lowercase().contains(needle) }270}271272@Composable273private fun AccentDragHandle() {274    val accent = LocalPeachAccent.current275    Row(276        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),277        horizontalArrangement = Arrangement.Center,278    ) {279        Box(280            modifier = Modifier281                .size(width = 32.dp, height = 4.dp)282                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),283        )284    }285}286287@Composable288private fun PickerBody(289    origin: String?,290    typedIdentity: String?,291    unlocked: Boolean,292    matchedEntries: List<VaultEntry>,293    allEntries: List<VaultEntry>,294    error: String?,295    busy: Boolean,296    canBiometric: Boolean,297    onBiometric: () -> Unit,298    onUnlock: (String) -> Unit,299    onPick: (VaultEntry) -> Unit,300    onCreateNew: () -> Unit,301) {302    val accent = LocalPeachAccent.current303    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {304        Text(305            text = "peach",306            color = accent,307            fontFamily = PeachType.Display,308            fontStyle = FontStyle.Italic,309            fontSize = 15.sp,310        )311        Text(312            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",313            color = peachColors.T2,314            fontFamily = PeachType.Mono,315            fontSize = 9.sp,316            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),317        )318319        if (!unlocked) {320            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)321        } else {322            // Always-on search field; debounced 200ms so a fast typer doesn't323            // thrash the filter. Vault-wide (not matched-only) so the user can324            // reach any entry from here.325            var rawQuery by remember { mutableStateOf("") }326            var debouncedQuery by remember { mutableStateOf("") }327            LaunchedEffect(rawQuery) {328                delay(SEARCH_DEBOUNCE_MS)329                debouncedQuery = rawQuery330            }331            SearchField(332                value = rawQuery,333                onValueChange = { rawQuery = it },334                placeholder = if (matchedEntries.isNotEmpty())335                    "search for another entry…" else "search vault…",336            )337338            // No-results-vs-empty-case is a single observable: an empty339            // entry list with no active query means the vault really had340            // no matches for this origin; a non-empty query producing an341            // empty list means the user typed something the vault can't find.342            // Either way, the picker still offers "+ Create new entry" so the343            // user can pivot to a save instead of being stranded.344            val visibleEntries = if (debouncedQuery.isBlank()) {345                matchedEntries346            } else {347                allEntries.filter { matchesPickerSearch(it, debouncedQuery) }348            }349            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()350351            if (matchedEntries.isEmpty() && debouncedQuery.isBlank()) {352                Text(353                    text = "No saved entries match this site.",354                    color = peachColors.T1,355                    fontFamily = PeachType.Body,356                    fontSize = 13.sp,357                    modifier = Modifier.padding(vertical = 6.dp),358                )359            } else if (showSearchEmpty) {360                Text(361                    text = "No entries match \"${debouncedQuery.trim()}\".",362                    color = peachColors.T1,363                    fontFamily = PeachType.Body,364                    fontSize = 13.sp,365                    modifier = Modifier.padding(vertical = 6.dp),366                )367            }368369            if (visibleEntries.isNotEmpty()) {370                LazyColumn {371                    items(visibleEntries, key = { it.id }) { entry ->372                        PickerRow(entry, onClick = { onPick(entry) })373                    }374                }375            }376377            // Header affordance, not buried at the end of the list — mirrors378            // the inline selector's "+" so the user is never trapped in the379            // search list when they want to make a new entry.380            CreateNewRow(origin = origin, typedIdentity = typedIdentity, onClick = onCreateNew)381        }382    }383}384385@Composable386private fun SearchField(387    value: String,388    onValueChange: (String) -> Unit,389    placeholder: String,390) {391    val accent = LocalPeachAccent.current392    BasicTextField(393        value = value,394        onValueChange = onValueChange,395        singleLine = true,396        textStyle = TextStyle(397            color = peachColors.T0,398            fontFamily = PeachType.Mono,399            fontSize = 14.sp,400        ),401        cursorBrush = SolidColor(accent),402        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),403        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),404        modifier = Modifier405            .fillMaxWidth()406            .background(peachColors.S2, RoundedCornerShape(8.dp))407            .padding(horizontal = 12.dp, vertical = 10.dp),408        decorationBox = { inner ->409            Box(modifier = Modifier.fillMaxWidth()) {410                if (value.isEmpty()) {411                    Text(412                        text = placeholder,413                        color = peachColors.T2,414                        fontFamily = PeachType.Mono,415                        fontSize = 12.sp,416                    )417                }418                inner()419            }420        },421    )422}423424@Composable425private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {426    val accent = LocalPeachAccent.current427    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"428        else "Create new entry"429    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }430    Row(431        modifier = Modifier432            .fillMaxWidth()433            .clickable(onClick = onClick)434            .padding(vertical = 10.dp),435        verticalAlignment = Alignment.CenterVertically,436    ) {437        Box(438            modifier = Modifier439                .size(34.dp)440                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),441            contentAlignment = Alignment.Center,442        ) {443            Text(444                text = "+",445                color = accent,446                fontFamily = PeachType.Display,447                fontStyle = FontStyle.Italic,448                fontSize = 18.sp,449            )450        }451        Column(modifier = Modifier.padding(start = 12.dp)) {452            Text(453                text = title,454                color = accent,455                fontFamily = PeachType.Body,456                fontSize = 14.sp,457                maxLines = 1,458                overflow = TextOverflow.Ellipsis,459            )460            if (subtitle != null) {461                Text(462                    text = "prefill: $subtitle",463                    color = peachColors.T2,464                    fontFamily = PeachType.Mono,465                    fontSize = 10.sp,466                    maxLines = 1,467                    overflow = TextOverflow.Ellipsis,468                )469            }470        }471    }472}473474@Composable475private fun UnlockBody(476    error: String?,477    busy: Boolean,478    canBiometric: Boolean,479    onBiometric: () -> Unit,480    onUnlock: (String) -> Unit,481) {482    val accent = LocalPeachAccent.current483    var password by remember { mutableStateOf("") }484485    Text(486        text = "Unlock to fill",487        color = peachColors.T0,488        fontFamily = PeachType.Body,489        fontSize = 15.sp,490        modifier = Modifier.padding(bottom = 10.dp),491    )492    BasicTextField(493        value = password,494        onValueChange = { password = it },495        singleLine = true,496        enabled = !busy,497        textStyle = TextStyle(498            color = peachColors.T0,499            fontFamily = PeachType.Mono,500            fontSize = 16.sp,501        ),502        cursorBrush = SolidColor(accent),503        visualTransformation = PasswordVisualTransformation(),504        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),505        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),506        modifier = Modifier507            .fillMaxWidth()508            .background(peachColors.S2, RoundedCornerShape(8.dp))509            .padding(horizontal = 12.dp, vertical = 12.dp),510    )511    if (error != null) {512        Text(513            text = error,514            color = peachColors.Breach,515            fontFamily = PeachType.Body,516            fontSize = 12.sp,517            modifier = Modifier.padding(top = 8.dp),518        )519    }520    Row(521        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),522        horizontalArrangement = Arrangement.spacedBy(20.dp),523        verticalAlignment = Alignment.CenterVertically,524    ) {525        if (canBiometric) {526            Text(527                text = "Use biometrics",528                color = accent,529                fontFamily = PeachType.Mono,530                fontSize = 13.sp,531                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),532            )533        }534        Text(535            text = if (busy) "Unlocking…" else "Unlock",536            color = if (password.isEmpty() || busy) peachColors.T2 else accent,537            fontFamily = PeachType.Mono,538            fontSize = 13.sp,539            modifier = Modifier540                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }541                .padding(vertical = 8.dp),542        )543    }544}545546@Composable547private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {548    Row(549        modifier = Modifier550            .fillMaxWidth()551            .clickable(onClick = onClick)552            .padding(vertical = 9.dp),553        verticalAlignment = Alignment.CenterVertically,554    ) {555        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)556        Column(modifier = Modifier.padding(start = 12.dp)) {557            Text(558                text = entry.title,559                color = peachColors.T0,560                fontFamily = PeachType.Body,561                fontSize = 15.sp,562                maxLines = 1,563                overflow = TextOverflow.Ellipsis,564            )565            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""566            if (sub.isNotBlank()) {567                Text(568                    text = sub,569                    color = peachColors.T2,570                    fontFamily = PeachType.Mono,571                    fontSize = 11.sp,572                    maxLines = 1,573                    overflow = TextOverflow.Ellipsis,574                )575            }576        }577    }578}579580/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */581private const val SEARCH_DEBOUNCE_MS = 200L