AutofillEntryPickerActivity.kt

25.8 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 originTrust = runCatching {111            AutofillOriginTrust.valueOf(112                intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN_TRUST).orEmpty(),113            )114        }.getOrDefault(AutofillOriginTrust.UNKNOWN)115        val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)116        val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)117        val parsed = ParsedStructure(118            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),119            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),120            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),121            webDomain = origin.takeIf { isWebOrigin },122            packageName = origin.takeUnless { isWebOrigin },123            originTrust = originTrust,124            verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),125        )126        val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT127128        setContent {129            PeachTheme(accent = accent) {130                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)131                val scope = rememberCoroutineScope()132                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }133                var error by remember { mutableStateOf<String?>(null) }134                var busy by remember { mutableStateOf(false) }135136                // Trusted origins may search the vault manually. Unknown origins stay137                // empty until the user types a registrable target domain in the picker.138                val allEntries = remember(unlocked) {139                    if (unlocked) vault.getEntries() else emptyList()140                }141                val matchedEntries = remember(unlocked, allEntries) {142                    if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()143                }144145                // Offer biometrics immediately — it is the whole point of146                // unlocking here rather than in the app, and a prompt the user147                // can dismiss straight onto the password field costs nothing.148                LaunchedEffect(unlocked) {149                    if (!unlocked && canBiometric()) {150                        tryBiometric(151                            onUnlocked = { unlocked = true },152                            onFail = { },153                        )154                    }155                }156157                ModalBottomSheet(158                    onDismissRequest = { cancel() },159                    sheetState = sheetState,160                    containerColor = peachColors.S1,161                    dragHandle = { AccentDragHandle() },162                ) {163                    PickerBody(164                        origin = parsed.origin,165                        originTrust = parsed.originTrust,166                        typedIdentity = typedUsername ?: typedEmail,167                        unlocked = unlocked,168                        matchedEntries = matchedEntries,169                        allEntries = allEntries,170                        error = error,171                        busy = busy,172                        canBiometric = canBiometric(),173                        onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },174                        onUnlock = { password ->175                            busy = true176                            error = null177                            scope.launch {178                                when (vault.unlock(password)) {179                                    is UnlockResult.Success -> unlocked = true180                                    UnlockResult.WrongPassword -> error = "Wrong master password."181                                    else -> error = "Couldn't unlock."182                                }183                                busy = false184                            }185                        },186                        onPick = { entry, confirmedTarget ->187                            returnDataset(parsed, entry, confirmedTarget)188                        },189                        onCreateNew = { confirmedTarget ->190                            launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget)191                        },192                    )193                }194            }195        }196    }197198    private fun canBiometric(): Boolean =199        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)200201    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {202        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()203        Biometrics.authenticateCrypto(204            activity = this,205            title = "Unlock Peach",206            subtitle = "Fill your saved details",207            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),208            onSuccess = { result ->209                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()210                // M-05: unlockWithKey copies `key` into the session's derivedKey,211                // so the caller-owned reference is now redundant. Zero it whether212                // the unlock succeeds or fails so the unwrapped biometric key213                // doesn't outlive the auth prompt.214                val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()215                lifecycleScope.launch {216                    try {217                        if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()218                    } finally {219                        com.peachpasswords.vault.VaultCrypto.zero(key)220                    }221                }222            },223            onFail = onFail,224        )225    }226227    private fun returnDataset(228        parsed: ParsedStructure,229        entry: VaultEntry,230        confirmedTarget: String?,231    ) {232        if (233            parsed.originTrust == AutofillOriginTrust.UNKNOWN &&234            AutofillResponseBuilder.matchEntries(listOf(entry), confirmedTarget).hits.isEmpty()235        ) {236            cancel()237            return238        }239        val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)240        if (dataset == null) { cancel(); return }241        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)242        setResult(RESULT_OK, reply)243        finish()244    }245246    /**247     * Cancel the picker and fire MainActivity on the new-entry path. The picker248     * returns CANCELED on purpose — the user is choosing to leave the autofill249     * prompt, not to fill it — and the launcher extra carries the host's typed250     * identity and origin so the new-entry editor opens pre-filled.251     */252    private fun launchNewEntry(253        parsed: ParsedStructure,254        typedUsername: String?,255        typedEmail: String?,256        confirmedTarget: String?,257    ) {258        val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {259            confirmedTarget?.let(::extractDomain) ?: return260        } else {261            parsed.origin262        }263        val intent = Intent(this, MainActivity::class.java).apply {264            putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)265            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)266            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)267            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)268            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)269        }270        startActivity(intent)271        cancel()272    }273274    private fun cancel() {275        setResult(RESULT_CANCELED)276        finish()277    }278279    @Suppress("DEPRECATION")280    private fun intentAutofillId(key: String): AutofillId? =281        if (android.os.Build.VERSION.SDK_INT >= 33) {282            intent.getParcelableExtra(key, AutofillId::class.java)283        } else {284            intent.getParcelableExtra(key)285        }286287    private companion object {288        const val MAX_ROWS = 12289    }290}291292internal fun autofillPickerEntries(293    entries: List<VaultEntry>,294    parsed: ParsedStructure,295    limit: Int,296): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)297298/**299 * Case-insensitive substring match across the four user-facing identity columns300 * the browser uses (title, username, email, url). Exposed at file scope so the301 * picker can call it directly and the test suite can exercise the same code.302 */303internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {304    if (query.isEmpty()) return true305    val needle = query.lowercase()306    return listOfNotNull(entry.title, entry.username, entry.email, entry.url)307        .any { it.lowercase().contains(needle) }308}309310@Composable311private fun AccentDragHandle() {312    val accent = LocalPeachAccent.current313    Row(314        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),315        horizontalArrangement = Arrangement.Center,316    ) {317        Box(318            modifier = Modifier319                .size(width = 32.dp, height = 4.dp)320                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),321        )322    }323}324325@Composable326private fun PickerBody(327    origin: String?,328    originTrust: AutofillOriginTrust,329    typedIdentity: String?,330    unlocked: Boolean,331    matchedEntries: List<VaultEntry>,332    allEntries: List<VaultEntry>,333    error: String?,334    busy: Boolean,335    canBiometric: Boolean,336    onBiometric: () -> Unit,337    onUnlock: (String) -> Unit,338    onPick: (VaultEntry, String?) -> Unit,339    onCreateNew: (String?) -> Unit,340) {341    val accent = LocalPeachAccent.current342    val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN343    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {344        Text(345            text = "peach",346            color = accent,347            fontFamily = PeachType.Display,348            fontStyle = FontStyle.Italic,349            fontSize = 15.sp,350        )351        Text(352            text = when {353                originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"354                origin.isNullOrBlank() -> "your vault"355                else -> "Suggested for $origin"356            },357            color = peachColors.T2,358            fontFamily = PeachType.Mono,359            fontSize = 9.sp,360            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),361        )362363        if (originTrust == AutofillOriginTrust.UNKNOWN) {364            Box(365                modifier = Modifier366                    .fillMaxWidth()367                    .padding(bottom = 14.dp)368                    .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))369                    .padding(12.dp),370            ) {371                Text(372                    text = "Peach couldn't verify this app or site. Enter the site domain yourself; only exact domain matches can be filled.",373                    color = peachColors.Breach,374                    fontFamily = PeachType.Body,375                    fontSize = 12.sp,376                )377            }378        }379380        if (!unlocked) {381            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)382        } else {383            // For an unverified caller, this field is a domain confirmation boundary,384            // not a vault-wide search box controlled by attacker-provided origin data.385            var rawQuery by remember { mutableStateOf("") }386            var debouncedQuery by remember { mutableStateOf("") }387            LaunchedEffect(rawQuery) {388                delay(SEARCH_DEBOUNCE_MS)389                debouncedQuery = rawQuery390            }391            val confirmedTarget = if (untrustedOrigin) {392                debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain)393            } else {394                null395            }396            SearchField(397                value = rawQuery,398                onValueChange = { rawQuery = it },399                placeholder = when {400                    untrustedOrigin -> "enter site domain, e.g. example.com"401                    matchedEntries.isNotEmpty() -> "search for another entry…"402                    else -> "search vault…"403                },404            )405406            val visibleEntries = when {407                untrustedOrigin && confirmedTarget == null -> emptyList()408                untrustedOrigin -> AutofillResponseBuilder409                    .matchEntries(allEntries, confirmedTarget)410                    .hits411                debouncedQuery.isBlank() -> matchedEntries412                else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }413            }414            val showSearchEmpty = debouncedQuery.isNotBlank() &&415                visibleEntries.isEmpty() &&416                (!untrustedOrigin || confirmedTarget != null)417418            val emptyMessage = when {419                untrustedOrigin && debouncedQuery.isBlank() ->420                    "Type the site domain to choose a saved login."421                untrustedOrigin && confirmedTarget == null ->422                    "Enter a full public domain such as example.com."423                untrustedOrigin && visibleEntries.isEmpty() ->424                    "No saved entries match $confirmedTarget."425                matchedEntries.isEmpty() && debouncedQuery.isBlank() ->426                    "No saved entries match this site."427                showSearchEmpty ->428                    "No entries match \"${debouncedQuery.trim()}\"."429                else -> null430            }431            if (emptyMessage != null) {432                Text(433                    text = emptyMessage,434                    color = peachColors.T1,435                    fontFamily = PeachType.Body,436                    fontSize = 13.sp,437                    modifier = Modifier.padding(vertical = 6.dp),438                )439            }440441            if (visibleEntries.isNotEmpty()) {442                LazyColumn {443                    items(visibleEntries, key = { it.id }) { entry ->444                        PickerRow(entry, onClick = { onPick(entry, confirmedTarget) })445                    }446                }447            }448449            val createOrigin = if (untrustedOrigin) confirmedTarget else origin450            if (!untrustedOrigin || createOrigin != null) {451                CreateNewRow(452                    origin = createOrigin,453                    typedIdentity = typedIdentity,454                    onClick = { onCreateNew(confirmedTarget) },455                )456            }457        }458    }459}460461@Composable462private fun SearchField(463    value: String,464    onValueChange: (String) -> Unit,465    placeholder: String,466) {467    val accent = LocalPeachAccent.current468    BasicTextField(469        value = value,470        onValueChange = onValueChange,471        singleLine = true,472        textStyle = TextStyle(473            color = peachColors.T0,474            fontFamily = PeachType.Mono,475            fontSize = 14.sp,476        ),477        cursorBrush = SolidColor(accent),478        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),479        keyboardActions = KeyboardActions(onSearch = { /* debounce handles filtering */ }),480        modifier = Modifier481            .fillMaxWidth()482            .background(peachColors.S2, RoundedCornerShape(8.dp))483            .padding(horizontal = 12.dp, vertical = 10.dp),484        decorationBox = { inner ->485            Box(modifier = Modifier.fillMaxWidth()) {486                if (value.isEmpty()) {487                    Text(488                        text = placeholder,489                        color = peachColors.T2,490                        fontFamily = PeachType.Mono,491                        fontSize = 12.sp,492                    )493                }494                inner()495            }496        },497    )498}499500@Composable501private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {502    val accent = LocalPeachAccent.current503    val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"504        else "Create new entry"505    val subtitle = typedIdentity?.takeIf { it.isNotBlank() }506    Row(507        modifier = Modifier508            .fillMaxWidth()509            .clickable(onClick = onClick)510            .padding(vertical = 10.dp),511        verticalAlignment = Alignment.CenterVertically,512    ) {513        Box(514            modifier = Modifier515                .size(34.dp)516                .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),517            contentAlignment = Alignment.Center,518        ) {519            Text(520                text = "+",521                color = accent,522                fontFamily = PeachType.Display,523                fontStyle = FontStyle.Italic,524                fontSize = 18.sp,525            )526        }527        Column(modifier = Modifier.padding(start = 12.dp)) {528            Text(529                text = title,530                color = accent,531                fontFamily = PeachType.Body,532                fontSize = 14.sp,533                maxLines = 1,534                overflow = TextOverflow.Ellipsis,535            )536            if (subtitle != null) {537                Text(538                    text = "prefill: $subtitle",539                    color = peachColors.T2,540                    fontFamily = PeachType.Mono,541                    fontSize = 10.sp,542                    maxLines = 1,543                    overflow = TextOverflow.Ellipsis,544                )545            }546        }547    }548}549550@Composable551private fun UnlockBody(552    error: String?,553    busy: Boolean,554    canBiometric: Boolean,555    onBiometric: () -> Unit,556    onUnlock: (String) -> Unit,557) {558    val accent = LocalPeachAccent.current559    var password by remember { mutableStateOf("") }560561    Text(562        text = "Unlock to fill",563        color = peachColors.T0,564        fontFamily = PeachType.Body,565        fontSize = 15.sp,566        modifier = Modifier.padding(bottom = 10.dp),567    )568    BasicTextField(569        value = password,570        onValueChange = { password = it },571        singleLine = true,572        enabled = !busy,573        textStyle = TextStyle(574            color = peachColors.T0,575            fontFamily = PeachType.Mono,576            fontSize = 16.sp,577        ),578        cursorBrush = SolidColor(accent),579        visualTransformation = PasswordVisualTransformation(),580        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),581        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),582        modifier = Modifier583            .fillMaxWidth()584            .background(peachColors.S2, RoundedCornerShape(8.dp))585            .padding(horizontal = 12.dp, vertical = 12.dp),586    )587    if (error != null) {588        Text(589            text = error,590            color = peachColors.Breach,591            fontFamily = PeachType.Body,592            fontSize = 12.sp,593            modifier = Modifier.padding(top = 8.dp),594        )595    }596    Row(597        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),598        horizontalArrangement = Arrangement.spacedBy(20.dp),599        verticalAlignment = Alignment.CenterVertically,600    ) {601        if (canBiometric) {602            Text(603                text = "Use biometrics",604                color = accent,605                fontFamily = PeachType.Mono,606                fontSize = 13.sp,607                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),608            )609        }610        Text(611            text = if (busy) "Unlocking…" else "Unlock",612            color = if (password.isEmpty() || busy) peachColors.T2 else accent,613            fontFamily = PeachType.Mono,614            fontSize = 13.sp,615            modifier = Modifier616                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }617                .padding(vertical = 8.dp),618        )619    }620}621622@Composable623private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {624    Row(625        modifier = Modifier626            .fillMaxWidth()627            .clickable(onClick = onClick)628            .padding(vertical = 9.dp),629        verticalAlignment = Alignment.CenterVertically,630    ) {631        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)632        Column(modifier = Modifier.padding(start = 12.dp)) {633            Text(634                text = entry.title,635                color = peachColors.T0,636                fontFamily = PeachType.Body,637                fontSize = 15.sp,638                maxLines = 1,639                overflow = TextOverflow.Ellipsis,640            )641            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""642            if (sub.isNotBlank()) {643                Text(644                    text = sub,645                    color = peachColors.T2,646                    fontFamily = PeachType.Mono,647                    fontSize = 11.sp,648                    maxLines = 1,649                    overflow = TextOverflow.Ellipsis,650                )651            }652        }653    }654}655656/** Debounce window for the picker search field — matches the inline selector's ~200ms feel. */657private const val SEARCH_DEBOUNCE_MS = 200L