AutofillEntryPickerActivity.kt

22.7 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                val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()189                lifecycleScope.launch {190                    if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()191                }192            },193            onFail = onFail,194        )195    }196197    private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {198        val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)199        if (dataset == null) { cancel(); return }200        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)201        setResult(RESULT_OK, reply)202        finish()203    }204205    /**206     * Cancel the picker and fire MainActivity on the new-entry path. The picker207     * returns CANCELED on purpose — the user is choosing to leave the autofill208     * prompt, not to fill it — and the launcher extra carries the host's typed209     * identity and origin so the new-entry editor opens pre-filled.210     */211    private fun launchNewEntry(parsed: ParsedStructure, typedUsername: String?, typedEmail: String?) {212        val typedIdentity = typedUsername ?: typedEmail213        val intent = Intent(this, MainActivity::class.java).apply {214            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)215            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, parsed.origin)216            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)217            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)218            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)219        }220        startActivity(intent)221        cancel()222    }223224    private fun cancel() {225        setResult(RESULT_CANCELED)226        finish()227    }228229    @Suppress("DEPRECATION")230    private fun intentAutofillId(key: String): AutofillId? =231        if (android.os.Build.VERSION.SDK_INT >= 33) {232            intent.getParcelableExtra(key, AutofillId::class.java)233        } else {234            intent.getParcelableExtra(key)235        }236237    private companion object {238        const val MAX_ROWS = 12239    }240}241242internal fun autofillPickerEntries(243    entries: List<VaultEntry>,244    parsed: ParsedStructure,245    limit: Int,246): List<VaultEntry> = when {247    parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit)248    parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit)249    else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)250}251252/**253 * Case-insensitive substring match across the four user-facing identity columns254 * the browser uses (title, username, email, url). Exposed at file scope so the255 * picker can call it directly and the test suite can exercise the same code.256 */257internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {258    if (query.isEmpty()) return true259    val needle = query.lowercase()260    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)261        .any { it.lowercase().contains(needle) }262}263264@Composable265private fun AccentDragHandle() {266    val accent = LocalPeachAccent.current267    Row(268        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),269        horizontalArrangement = Arrangement.Center,270    ) {271        Box(272            modifier = Modifier273                .size(width = 32.dp, height = 4.dp)274                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),275        )276    }277}278279@Composable280private fun PickerBody(281    origin: String?,282    typedIdentity: String?,283    unlocked: Boolean,284    matchedEntries: List<VaultEntry>,285    allEntries: List<VaultEntry>,286    error: String?,287    busy: Boolean,288    canBiometric: Boolean,289    onBiometric: () -> Unit,290    onUnlock: (String) -> Unit,291    onPick: (VaultEntry) -> Unit,292    onCreateNew: () -> Unit,293) {294    val accent = LocalPeachAccent.current295    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {296        Text(297            text = "peach",298            color = accent,299            fontFamily = PeachType.Display,300            fontStyle = FontStyle.Italic,301            fontSize = 15.sp,302        )303        Text(304            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",305            color = peachColors.T2,306            fontFamily = PeachType.Mono,307            fontSize = 9.sp,308            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),309        )310311        if (!unlocked) {312            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)313        } else {314            // Always-on search field; debounced 200ms so a fast typer doesn't315            // thrash the filter. Vault-wide (not matched-only) so the user can316            // reach any entry from here.317            var rawQuery by remember { mutableStateOf("") }318            var debouncedQuery by remember { mutableStateOf("") }319            LaunchedEffect(rawQuery) {320                delay(SEARCH_DEBOUNCE_MS)321                debouncedQuery = rawQuery322            }323            SearchField(324                value = rawQuery,325                onValueChange = { rawQuery = it },326                placeholder = if (matchedEntries.isNotEmpty())327                    "search for another entry…" else "search vault…",328            )329330            // No-results-vs-empty-case is a single observable: an empty331            // entry list with no active query means the vault really had332            // no matches for this origin; a non-empty query producing an333            // empty list means the user typed something the vault can't find.334            // Either way, the picker still offers "+ Create new entry" so the335            // user can pivot to a save instead of being stranded.336            val visibleEntries = if (debouncedQuery.isBlank()) {337                matchedEntries338            } else {339                allEntries.filter { matchesPickerSearch(it, debouncedQuery) }340            }341            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()342343            if (matchedEntries.isEmpty() && debouncedQuery.isBlank()) {344                Text(345                    text = "No saved entries match this site.",346                    color = peachColors.T1,347                    fontFamily = PeachType.Body,348                    fontSize = 13.sp,349                    modifier = Modifier.padding(vertical = 6.dp),350                )351            } else if (showSearchEmpty) {352                Text(353                    text = "No entries match \"${debouncedQuery.trim()}\".",354                    color = peachColors.T1,355                    fontFamily = PeachType.Body,356                    fontSize = 13.sp,357                    modifier = Modifier.padding(vertical = 6.dp),358                )359            }360361            if (visibleEntries.isNotEmpty()) {362                LazyColumn {363                    items(visibleEntries, key = { it.id }) { entry ->364                        PickerRow(entry, onClick = { onPick(entry) })365                    }366                }367            }368369            // Header affordance, not buried at the end of the list — mirrors370            // the inline selector's "+" so the user is never trapped in the371            // search list when they want to make a new entry.372            CreateNewRow(origin = origin, typedIdentity = typedIdentity, onClick = onCreateNew)373        }374    }375}376377@Composable378private fun SearchField(379    value: String,380    onValueChange: (String) -> Unit,381    placeholder: String,382) {383    val accent = LocalPeachAccent.current384    BasicTextField(385        value = value,386        onValueChange = onValueChange,387        singleLine = true,388        textStyle = TextStyle(389            color = peachColors.T0,390            fontFamily = PeachType.Mono,391            fontSize = 14.sp,392        ),393        cursorBrush = SolidColor(accent),394        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),395        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),396        modifier = Modifier397            .fillMaxWidth()398            .background(peachColors.S2, RoundedCornerShape(8.dp))399            .padding(horizontal = 12.dp, vertical = 10.dp),400        decorationBox = { inner ->401            Box(modifier = Modifier.fillMaxWidth()) {402                if (value.isEmpty()) {403                    Text(404                        text = placeholder,405                        color = peachColors.T2,406                        fontFamily = PeachType.Mono,407                        fontSize = 12.sp,408                    )409                }410                inner()411            }412        },413    )414}415416@Composable417private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {418    val accent = LocalPeachAccent.current419    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"420        else "Create new entry"421    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }422    Row(423        modifier = Modifier424            .fillMaxWidth()425            .clickable(onClick = onClick)426            .padding(vertical = 10.dp),427        verticalAlignment = Alignment.CenterVertically,428    ) {429        Box(430            modifier = Modifier431                .size(34.dp)432                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),433            contentAlignment = Alignment.Center,434        ) {435            Text(436                text = "+",437                color = accent,438                fontFamily = PeachType.Display,439                fontStyle = FontStyle.Italic,440                fontSize = 18.sp,441            )442        }443        Column(modifier = Modifier.padding(start = 12.dp)) {444            Text(445                text = title,446                color = accent,447                fontFamily = PeachType.Body,448                fontSize = 14.sp,449                maxLines = 1,450                overflow = TextOverflow.Ellipsis,451            )452            if (subtitle != null) {453                Text(454                    text = "prefill: $subtitle",455                    color = peachColors.T2,456                    fontFamily = PeachType.Mono,457                    fontSize = 10.sp,458                    maxLines = 1,459                    overflow = TextOverflow.Ellipsis,460                )461            }462        }463    }464}465466@Composable467private fun UnlockBody(468    error: String?,469    busy: Boolean,470    canBiometric: Boolean,471    onBiometric: () -> Unit,472    onUnlock: (String) -> Unit,473) {474    val accent = LocalPeachAccent.current475    var password by remember { mutableStateOf("") }476477    Text(478        text = "Unlock to fill",479        color = peachColors.T0,480        fontFamily = PeachType.Body,481        fontSize = 15.sp,482        modifier = Modifier.padding(bottom = 10.dp),483    )484    BasicTextField(485        value = password,486        onValueChange = { password = it },487        singleLine = true,488        enabled = !busy,489        textStyle = TextStyle(490            color = peachColors.T0,491            fontFamily = PeachType.Mono,492            fontSize = 16.sp,493        ),494        cursorBrush = SolidColor(accent),495        visualTransformation = PasswordVisualTransformation(),496        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),497        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),498        modifier = Modifier499            .fillMaxWidth()500            .background(peachColors.S2, RoundedCornerShape(8.dp))501            .padding(horizontal = 12.dp, vertical = 12.dp),502    )503    if (error != null) {504        Text(505            text = error,506            color = peachColors.Breach,507            fontFamily = PeachType.Body,508            fontSize = 12.sp,509            modifier = Modifier.padding(top = 8.dp),510        )511    }512    Row(513        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),514        horizontalArrangement = Arrangement.spacedBy(20.dp),515        verticalAlignment = Alignment.CenterVertically,516    ) {517        if (canBiometric) {518            Text(519                text = "Use biometrics",520                color = accent,521                fontFamily = PeachType.Mono,522                fontSize = 13.sp,523                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),524            )525        }526        Text(527            text = if (busy) "Unlocking…" else "Unlock",528            color = if (password.isEmpty() || busy) peachColors.T2 else accent,529            fontFamily = PeachType.Mono,530            fontSize = 13.sp,531            modifier = Modifier532                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }533                .padding(vertical = 8.dp),534        )535    }536}537538@Composable539private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {540    Row(541        modifier = Modifier542            .fillMaxWidth()543            .clickable(onClick = onClick)544            .padding(vertical = 9.dp),545        verticalAlignment = Alignment.CenterVertically,546    ) {547        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)548        Column(modifier = Modifier.padding(start = 12.dp)) {549            Text(550                text = entry.title,551                color = peachColors.T0,552                fontFamily = PeachType.Body,553                fontSize = 15.sp,554                maxLines = 1,555                overflow = TextOverflow.Ellipsis,556            )557            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""558            if (sub.isNotBlank()) {559                Text(560                    text = sub,561                    color = peachColors.T2,562                    fontFamily = PeachType.Mono,563                    fontSize = 11.sp,564                    maxLines = 1,565                    overflow = TextOverflow.Ellipsis,566                )567            }568        }569    }570}571572/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */573private const val SEARCH_DEBOUNCE_MS = 200L