AutofillEntryPickerActivity.kt

23.6 KB · raw

1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Bundle5import android.view.WindowManager6import android.view.autofill.AutofillId7import android.view.autofill.AutofillManager8import androidx.activity.compose.setContent9import androidx.compose.foundation.background10import androidx.compose.foundation.clickable11import androidx.compose.foundation.layout.Arrangement12import androidx.compose.foundation.layout.Box13import androidx.compose.foundation.layout.Column14import androidx.compose.foundation.layout.Row15import androidx.compose.foundation.layout.fillMaxWidth16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.size18import androidx.compose.foundation.lazy.LazyColumn19import androidx.compose.foundation.lazy.items20import androidx.compose.foundation.shape.RoundedCornerShape21import androidx.compose.material3.ExperimentalMaterial3Api22import androidx.compose.material3.ModalBottomSheet23import androidx.compose.material3.Text24import androidx.compose.material3.rememberModalBottomSheetState25import androidx.compose.foundation.text.BasicTextField26import androidx.compose.foundation.text.KeyboardActions27import androidx.compose.foundation.text.KeyboardOptions28import androidx.compose.runtime.Composable29import androidx.compose.runtime.LaunchedEffect30import androidx.compose.runtime.getValue31import androidx.compose.runtime.mutableStateOf32import androidx.compose.runtime.remember33import androidx.compose.runtime.rememberCoroutineScope34import androidx.compose.runtime.setValue35import androidx.compose.ui.graphics.SolidColor36import androidx.compose.ui.text.TextStyle37import androidx.compose.ui.text.input.ImeAction38import androidx.compose.ui.text.input.KeyboardType39import androidx.compose.ui.text.input.PasswordVisualTransformation40import androidx.compose.ui.Alignment41import androidx.compose.ui.Modifier42import androidx.compose.ui.text.font.FontStyle43import androidx.compose.ui.text.style.TextOverflow44import androidx.compose.ui.unit.dp45import androidx.compose.ui.unit.sp46import androidx.fragment.app.FragmentActivity47import androidx.lifecycle.lifecycleScope48import com.peachpasswords.BuildConfig49import com.peachpasswords.MainActivity50import com.peachpasswords.shared.extractDomain51import com.peachpasswords.ui.lock.BiometricKeyStore52import com.peachpasswords.ui.lock.Biometrics53import com.peachpasswords.ui.components.FaviconInitials54import com.peachpasswords.ui.theme.ACCENT_COLORS55import com.peachpasswords.ui.theme.DEFAULT_ACCENT56import com.peachpasswords.ui.theme.LocalPeachAccent57import com.peachpasswords.ui.theme.peachColors58import com.peachpasswords.ui.theme.PeachTheme59import com.peachpasswords.ui.theme.PeachType60import com.peachpasswords.vault.UnlockResult61import com.peachpasswords.vault.VaultEntry62import com.peachpasswords.vault.VaultRepository63import dagger.hilt.android.AndroidEntryPoint64import kotlinx.coroutines.delay65import kotlinx.coroutines.launch66import javax.inject.Inject6768/**69 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"70 * in the system autofill drop-down. Lists matching vault entries using the same71 * row vocabulary as the vault list; picking one returns a filled72 * [android.service.autofill.Dataset] to the framework via73 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].74 *75 * Inline-mirror of the browser's selector: always-on search field, vault-wide76 * match against title/username/email/url, and a "+ Create new entry" affordance77 * in the header. The affordance cancels this activity and launches [MainActivity]78 * with [AutofillIntents.EXTRA_OPEN_NEW_ENTRY] so the user can save what they had79 * typed on the host page.80 *81 * Unlocking happens HERE, in the sheet. It used to send the user to MainActivity82 * and return RESULT_CANCELED, which produced a loop with no exit: the framework83 * treats a cancelled auth as "not filled" and re-offers the same prompt, so the84 * user unlocked the app, came back to a field that still asked them to unlock,85 * and had no way to tell that anything had happened. Autofill is a modal86 * interruption of someone else's app — leaving it to go somewhere else is the87 * one thing it must not do.88 */89@AndroidEntryPoint90class AutofillEntryPickerActivity : FragmentActivity() {9192    @Inject lateinit var vault: VaultRepository9394    @OptIn(ExperimentalMaterial3Api::class)95    override fun onCreate(savedInstanceState: Bundle?) {96        super.onCreate(savedInstanceState)9798        // H-05: separate window from MainActivity, so it does NOT inherit the99        // release-wide FLAG_SECURE set there. Show vault metadata and accept the100        // master password — protect against screenshots / recents thumbnails.101        if (!BuildConfig.DEBUG) {102            window.setFlags(103                WindowManager.LayoutParams.FLAG_SECURE,104                WindowManager.LayoutParams.FLAG_SECURE,105            )106        }107108        val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)109        val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)110        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)111        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)112        val parsed = ParsedStructure(113            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),114            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),115            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),116            webDomain = origin.takeIf { isWebOrigin },117            packageName = origin.takeUnless { isWebOrigin },118        )119        val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT120121        setContent {122            PeachTheme(accent = accent) {123                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)124                val scope = rememberCoroutineScope()125                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }126                var error by remember { mutableStateOf<String?>(null) }127                var busy by remember { mutableStateOf(false) }128129                // The picker filters across the entire vault when the user types130                // a search, so the unlocked entry list is the universe of choices —131                // not just the matched ones. The header still surfaces the matched132                // count separately so users know what the search is broadening.133                val allEntries = remember(unlocked) {134                    if (unlocked) vault.getEntries() else emptyList()135                }136                val matchedEntries = remember(unlocked, allEntries) {137                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()138                }139140                // Offer biometrics immediately — it is the whole point of141                // unlocking here rather than in the app, and a prompt the user142                // can dismiss straight onto the password field costs nothing.143                LaunchedEffect(unlocked) {144                    if (!unlocked && canBiometric()) {145                        tryBiometric(146                            onUnlocked = { unlocked = true },147                            onFail = { },148                        )149                    }150                }151152                ModalBottomSheet(153                    onDismissRequest = { cancel() },154                    sheetState = sheetState,155                    containerColor = peachColors.S1,156                    dragHandle = { AccentDragHandle() },157                ) {158                    PickerBody(159                        origin = parsed.origin,160                        typedIdentity = typedUsername ?: typedEmail,161                        unlocked = unlocked,162                        matchedEntries = matchedEntries,163                        allEntries = allEntries,164                        error = error,165                        busy = busy,166                        canBiometric = canBiometric(),167                        onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },168                        onUnlock = { password ->169                            busy = true170                            error = null171                            scope.launch {172                                when (vault.unlock(password)) {173                                    is UnlockResult.Success -> unlocked = true174                                    UnlockResult.WrongPassword -> error = "Wrong master password."175                                    else -> error = "Couldn't unlock."176                                }177                                busy = false178                            }179                        },180                        onPick = { returnDataset(parsed, it) },181                        onCreateNew = { launchNewEntry(parsed, typedUsername, typedEmail) },182                    )183                }184            }185        }186    }187188    private fun canBiometric(): Boolean =189        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)190191    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {192        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()193        Biometrics.authenticateCrypto(194            activity = this,195            title = "Unlock Peach",196            subtitle = "Fill your saved details",197            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),198            onSuccess = { result ->199                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()200                // M-05: unlockWithKey copies `key` into the session's derivedKey,201                // so the caller-owned reference is now redundant. Zero it whether202                // the unlock succeeds or fails so the unwrapped biometric key203                // doesn't outlive the auth prompt.204                val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()205                lifecycleScope.launch {206                    try {207                        if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()208                    } finally {209                        com.peachpasswords.vault.VaultCrypto.zero(key)210                    }211                }212            },213            onFail = onFail,214        )215    }216217    private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {218        val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)219        if (dataset == null) { cancel(); return }220        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)221        setResult(RESULT_OK, reply)222        finish()223    }224225    /**226     * Cancel the picker and fire MainActivity on the new-entry path. The picker227     * returns CANCELED on purpose — the user is choosing to leave the autofill228     * prompt, not to fill it — and the launcher extra carries the host's typed229     * identity and origin so the new-entry editor opens pre-filled.230     */231    private fun launchNewEntry(parsed: ParsedStructure, typedUsername: String?, typedEmail: String?) {232        val typedIdentity = typedUsername ?: typedEmail233        val intent = Intent(this, MainActivity::class.java).apply {234            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)235            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, parsed.origin)236            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)237            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)238            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)239        }240        startActivity(intent)241        cancel()242    }243244    private fun cancel() {245        setResult(RESULT_CANCELED)246        finish()247    }248249    @Suppress("DEPRECATION")250    private fun intentAutofillId(key: String): AutofillId? =251        if (android.os.Build.VERSION.SDK_INT >= 33) {252            intent.getParcelableExtra(key, AutofillId::class.java)253        } else {254            intent.getParcelableExtra(key)255        }256257    private companion object {258        const val MAX_ROWS = 12259    }260}261262internal fun autofillPickerEntries(263    entries: List<VaultEntry>,264    parsed: ParsedStructure,265    limit: Int,266): List<VaultEntry> = when {267    parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit)268    parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit)269    else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)270}271272/**273 * Case-insensitive substring match across the four user-facing identity columns274 * the browser uses (title, username, email, url). Exposed at file scope so the275 * picker can call it directly and the test suite can exercise the same code.276 */277internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {278    if (query.isEmpty()) return true279    val needle = query.lowercase()280    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)281        .any { it.lowercase().contains(needle) }282}283284@Composable285private fun AccentDragHandle() {286    val accent = LocalPeachAccent.current287    Row(288        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),289        horizontalArrangement = Arrangement.Center,290    ) {291        Box(292            modifier = Modifier293                .size(width = 32.dp, height = 4.dp)294                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),295        )296    }297}298299@Composable300private fun PickerBody(301    origin: String?,302    typedIdentity: String?,303    unlocked: Boolean,304    matchedEntries: List<VaultEntry>,305    allEntries: List<VaultEntry>,306    error: String?,307    busy: Boolean,308    canBiometric: Boolean,309    onBiometric: () -> Unit,310    onUnlock: (String) -> Unit,311    onPick: (VaultEntry) -> Unit,312    onCreateNew: () -> Unit,313) {314    val accent = LocalPeachAccent.current315    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {316        Text(317            text = "peach",318            color = accent,319            fontFamily = PeachType.Display,320            fontStyle = FontStyle.Italic,321            fontSize = 15.sp,322        )323        Text(324            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",325            color = peachColors.T2,326            fontFamily = PeachType.Mono,327            fontSize = 9.sp,328            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),329        )330331        if (!unlocked) {332            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)333        } else {334            // Always-on search field; debounced 200ms so a fast typer doesn't335            // thrash the filter. Vault-wide (not matched-only) so the user can336            // reach any entry from here.337            var rawQuery by remember { mutableStateOf("") }338            var debouncedQuery by remember { mutableStateOf("") }339            LaunchedEffect(rawQuery) {340                delay(SEARCH_DEBOUNCE_MS)341                debouncedQuery = rawQuery342            }343            SearchField(344                value = rawQuery,345                onValueChange = { rawQuery = it },346                placeholder = if (matchedEntries.isNotEmpty())347                    "search for another entry…" else "search vault…",348            )349350            // No-results-vs-empty-case is a single observable: an empty351            // entry list with no active query means the vault really had352            // no matches for this origin; a non-empty query producing an353            // empty list means the user typed something the vault can't find.354            // Either way, the picker still offers "+ Create new entry" so the355            // user can pivot to a save instead of being stranded.356            val visibleEntries = if (debouncedQuery.isBlank()) {357                matchedEntries358            } else {359                allEntries.filter { matchesPickerSearch(it, debouncedQuery) }360            }361            val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()362363            if (matchedEntries.isEmpty() && debouncedQuery.isBlank()) {364                Text(365                    text = "No saved entries match this site.",366                    color = peachColors.T1,367                    fontFamily = PeachType.Body,368                    fontSize = 13.sp,369                    modifier = Modifier.padding(vertical = 6.dp),370                )371            } else if (showSearchEmpty) {372                Text(373                    text = "No entries match \"${debouncedQuery.trim()}\".",374                    color = peachColors.T1,375                    fontFamily = PeachType.Body,376                    fontSize = 13.sp,377                    modifier = Modifier.padding(vertical = 6.dp),378                )379            }380381            if (visibleEntries.isNotEmpty()) {382                LazyColumn {383                    items(visibleEntries, key = { it.id }) { entry ->384                        PickerRow(entry, onClick = { onPick(entry) })385                    }386                }387            }388389            // Header affordance, not buried at the end of the list — mirrors390            // the inline selector's "+" so the user is never trapped in the391            // search list when they want to make a new entry.392            CreateNewRow(origin = origin, typedIdentity = typedIdentity, onClick = onCreateNew)393        }394    }395}396397@Composable398private fun SearchField(399    value: String,400    onValueChange: (String) -> Unit,401    placeholder: String,402) {403    val accent = LocalPeachAccent.current404    BasicTextField(405        value = value,406        onValueChange = onValueChange,407        singleLine = true,408        textStyle = TextStyle(409            color = peachColors.T0,410            fontFamily = PeachType.Mono,411            fontSize = 14.sp,412        ),413        cursorBrush = SolidColor(accent),414        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),415        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),416        modifier = Modifier417            .fillMaxWidth()418            .background(peachColors.S2, RoundedCornerShape(8.dp))419            .padding(horizontal = 12.dp, vertical = 10.dp),420        decorationBox = { inner ->421            Box(modifier = Modifier.fillMaxWidth()) {422                if (value.isEmpty()) {423                    Text(424                        text = placeholder,425                        color = peachColors.T2,426                        fontFamily = PeachType.Mono,427                        fontSize = 12.sp,428                    )429                }430                inner()431            }432        },433    )434}435436@Composable437private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {438    val accent = LocalPeachAccent.current439    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"440        else "Create new entry"441    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }442    Row(443        modifier = Modifier444            .fillMaxWidth()445            .clickable(onClick = onClick)446            .padding(vertical = 10.dp),447        verticalAlignment = Alignment.CenterVertically,448    ) {449        Box(450            modifier = Modifier451                .size(34.dp)452                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),453            contentAlignment = Alignment.Center,454        ) {455            Text(456                text = "+",457                color = accent,458                fontFamily = PeachType.Display,459                fontStyle = FontStyle.Italic,460                fontSize = 18.sp,461            )462        }463        Column(modifier = Modifier.padding(start = 12.dp)) {464            Text(465                text = title,466                color = accent,467                fontFamily = PeachType.Body,468                fontSize = 14.sp,469                maxLines = 1,470                overflow = TextOverflow.Ellipsis,471            )472            if (subtitle != null) {473                Text(474                    text = "prefill: $subtitle",475                    color = peachColors.T2,476                    fontFamily = PeachType.Mono,477                    fontSize = 10.sp,478                    maxLines = 1,479                    overflow = TextOverflow.Ellipsis,480                )481            }482        }483    }484}485486@Composable487private fun UnlockBody(488    error: String?,489    busy: Boolean,490    canBiometric: Boolean,491    onBiometric: () -> Unit,492    onUnlock: (String) -> Unit,493) {494    val accent = LocalPeachAccent.current495    var password by remember { mutableStateOf("") }496497    Text(498        text = "Unlock to fill",499        color = peachColors.T0,500        fontFamily = PeachType.Body,501        fontSize = 15.sp,502        modifier = Modifier.padding(bottom = 10.dp),503    )504    BasicTextField(505        value = password,506        onValueChange = { password = it },507        singleLine = true,508        enabled = !busy,509        textStyle = TextStyle(510            color = peachColors.T0,511            fontFamily = PeachType.Mono,512            fontSize = 16.sp,513        ),514        cursorBrush = SolidColor(accent),515        visualTransformation = PasswordVisualTransformation(),516        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),517        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),518        modifier = Modifier519            .fillMaxWidth()520            .background(peachColors.S2, RoundedCornerShape(8.dp))521            .padding(horizontal = 12.dp, vertical = 12.dp),522    )523    if (error != null) {524        Text(525            text = error,526            color = peachColors.Breach,527            fontFamily = PeachType.Body,528            fontSize = 12.sp,529            modifier = Modifier.padding(top = 8.dp),530        )531    }532    Row(533        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),534        horizontalArrangement = Arrangement.spacedBy(20.dp),535        verticalAlignment = Alignment.CenterVertically,536    ) {537        if (canBiometric) {538            Text(539                text = "Use biometrics",540                color = accent,541                fontFamily = PeachType.Mono,542                fontSize = 13.sp,543                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),544            )545        }546        Text(547            text = if (busy) "Unlocking…" else "Unlock",548            color = if (password.isEmpty() || busy) peachColors.T2 else accent,549            fontFamily = PeachType.Mono,550            fontSize = 13.sp,551            modifier = Modifier552                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }553                .padding(vertical = 8.dp),554        )555    }556}557558@Composable559private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {560    Row(561        modifier = Modifier562            .fillMaxWidth()563            .clickable(onClick = onClick)564            .padding(vertical = 9.dp),565        verticalAlignment = Alignment.CenterVertically,566    ) {567        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)568        Column(modifier = Modifier.padding(start = 12.dp)) {569            Text(570                text = entry.title,571                color = peachColors.T0,572                fontFamily = PeachType.Body,573                fontSize = 15.sp,574                maxLines = 1,575                overflow = TextOverflow.Ellipsis,576            )577            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""578            if (sub.isNotBlank()) {579                Text(580                    text = sub,581                    color = peachColors.T2,582                    fontFamily = PeachType.Mono,583                    fontSize = 11.sp,584                    maxLines = 1,585                    overflow = TextOverflow.Ellipsis,586                )587            }588        }589    }590}591592/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */593private const val SEARCH_DEBOUNCE_MS = 200L