AutofillEntryPickerActivity.kt

14.6 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.graphics.Color42import 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.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.launch63import javax.inject.Inject6465/**66 * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach"67 * in the system autofill drop-down. Lists matching vault entries using the same68 * row vocabulary as the vault list; picking one returns a filled69 * [android.service.autofill.Dataset] to the framework via70 * [AutofillManager.EXTRA_AUTHENTICATION_RESULT].71 *72 * Unlocking happens HERE, in the sheet. It used to send the user to73 * MainActivity and return RESULT_CANCELED, which produced a loop with no exit:74 * the framework treats a cancelled auth as "not filled" and re-offers the same75 * prompt, so the user unlocked the app, came back to a field that still asked76 * them to unlock, and had no way to tell that anything had happened. Autofill77 * is a modal interruption of someone else's app — leaving it to go somewhere78 * else is the one thing it must not do.79 */80@AndroidEntryPoint81class AutofillEntryPickerActivity : FragmentActivity() {8283    @Inject lateinit var vault: VaultRepository8485    @OptIn(ExperimentalMaterial3Api::class)86    override fun onCreate(savedInstanceState: Bundle?) {87        super.onCreate(savedInstanceState)8889        val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)90        val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)91        val parsed = ParsedStructure(92            usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),93            passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),94            otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),95            webDomain = origin.takeIf { isWebOrigin },96            packageName = origin.takeUnless { isWebOrigin },97        )98        val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT99100        setContent {101            PeachTheme(accent = accent) {102                val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)103                val scope = rememberCoroutineScope()104                var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }105                var error by remember { mutableStateOf<String?>(null) }106                var busy by remember { mutableStateOf(false) }107108                val entries = remember(unlocked) {109                    if (unlocked) autofillPickerEntries(vault.getEntries(), parsed, MAX_ROWS) else emptyList()110                }111112                // Offer biometrics immediately — it is the whole point of113                // unlocking here rather than in the app, and a prompt the user114                // can dismiss straight onto the password field costs nothing.115                LaunchedEffect(unlocked) {116                    if (!unlocked && canBiometric()) {117                        tryBiometric(118                            onUnlocked = { unlocked = true },119                            onFail = { },120                        )121                    }122                }123124                ModalBottomSheet(125                    onDismissRequest = { cancel() },126                    sheetState = sheetState,127                    containerColor = peachColors.S1,128                    dragHandle = { AccentDragHandle() },129                ) {130                    PickerBody(131                        origin = parsed.origin,132                        unlocked = unlocked,133                        entries = entries,134                        error = error,135                        busy = busy,136                        canBiometric = canBiometric(),137                        onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },138                        onUnlock = { password ->139                            busy = true140                            error = null141                            scope.launch {142                                when (vault.unlock(password)) {143                                    is UnlockResult.Success -> unlocked = true144                                    UnlockResult.WrongPassword -> error = "Wrong master password."145                                    else -> error = "Couldn't unlock."146                                }147                                busy = false148                            }149                        },150                        onPick = { returnDataset(parsed, it) },151                    )152                }153            }154        }155    }156157    private fun canBiometric(): Boolean =158        Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)159160    private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {161        val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()162        Biometrics.authenticateCrypto(163            activity = this,164            title = "Unlock Peach",165            subtitle = "Fill your saved details",166            crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),167            onSuccess = { result ->168                val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()169                val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()170                lifecycleScope.launch {171                    if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()172                }173            },174            onFail = onFail,175        )176    }177178    private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {179        val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)180        if (dataset == null) { cancel(); return }181        val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)182        setResult(RESULT_OK, reply)183        finish()184    }185186    private fun cancel() {187        setResult(RESULT_CANCELED)188        finish()189    }190191    @Suppress("DEPRECATION")192    private fun intentAutofillId(key: String): AutofillId? =193        if (android.os.Build.VERSION.SDK_INT >= 33) {194            intent.getParcelableExtra(key, AutofillId::class.java)195        } else {196            intent.getParcelableExtra(key)197        }198199    private companion object {200        const val MAX_ROWS = 12201    }202}203204internal fun autofillPickerEntries(205    entries: List<VaultEntry>,206    parsed: ParsedStructure,207    limit: Int,208): List<VaultEntry> = when {209    parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit)210    parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit)211    else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)212}213214@Composable215private fun AccentDragHandle() {216    val accent = LocalPeachAccent.current217    Row(218        modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),219        horizontalArrangement = Arrangement.Center,220    ) {221        Box(222            modifier = Modifier223                .size(width = 32.dp, height = 4.dp)224                .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),225        )226    }227}228229@Composable230private fun PickerBody(231    origin: String?,232    unlocked: Boolean,233    entries: List<VaultEntry>,234    error: String?,235    busy: Boolean,236    canBiometric: Boolean,237    onBiometric: () -> Unit,238    onUnlock: (String) -> Unit,239    onPick: (VaultEntry) -> Unit,240) {241    val accent = LocalPeachAccent.current242    Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {243        Text(244            text = "peach",245            color = accent,246            fontFamily = PeachType.Display,247            fontStyle = FontStyle.Italic,248            fontSize = 15.sp,249        )250        Text(251            text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",252            color = peachColors.T2,253            fontFamily = PeachType.Mono,254            fontSize = 9.sp,255            modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),256        )257258        if (!unlocked) {259            UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)260        } else if (entries.isEmpty()) {261            Text(262                text = "No saved entries match this site.",263                color = peachColors.T1,264                fontFamily = PeachType.Body,265                fontSize = 14.sp,266            )267        } else {268            LazyColumn {269                items(entries, key = { it.id }) { entry ->270                    PickerRow(entry, onClick = { onPick(entry) })271                }272            }273        }274    }275}276277@Composable278private fun UnlockBody(279    error: String?,280    busy: Boolean,281    canBiometric: Boolean,282    onBiometric: () -> Unit,283    onUnlock: (String) -> Unit,284) {285    val accent = LocalPeachAccent.current286    var password by remember { mutableStateOf("") }287288    Text(289        text = "Unlock to fill",290        color = peachColors.T0,291        fontFamily = PeachType.Body,292        fontSize = 15.sp,293        modifier = Modifier.padding(bottom = 10.dp),294    )295    BasicTextField(296        value = password,297        onValueChange = { password = it },298        singleLine = true,299        enabled = !busy,300        textStyle = TextStyle(301            color = peachColors.T0,302            fontFamily = PeachType.Mono,303            fontSize = 16.sp,304        ),305        cursorBrush = SolidColor(accent),306        visualTransformation = PasswordVisualTransformation(),307        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),308        keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),309        modifier = Modifier310            .fillMaxWidth()311            .background(peachColors.S2, RoundedCornerShape(8.dp))312            .padding(horizontal = 12.dp, vertical = 12.dp),313    )314    if (error != null) {315        Text(316            text = error,317            color = peachColors.Breach,318            fontFamily = PeachType.Body,319            fontSize = 12.sp,320            modifier = Modifier.padding(top = 8.dp),321        )322    }323    Row(324        modifier = Modifier.fillMaxWidth().padding(top = 14.dp),325        horizontalArrangement = Arrangement.spacedBy(20.dp),326        verticalAlignment = Alignment.CenterVertically,327    ) {328        if (canBiometric) {329            Text(330                text = "Use biometrics",331                color = accent,332                fontFamily = PeachType.Mono,333                fontSize = 13.sp,334                modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),335            )336        }337        Text(338            text = if (busy) "Unlocking…" else "Unlock",339            color = if (password.isEmpty() || busy) peachColors.T2 else accent,340            fontFamily = PeachType.Mono,341            fontSize = 13.sp,342            modifier = Modifier343                .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }344                .padding(vertical = 8.dp),345        )346    }347}348349@Composable350private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {351    Row(352        modifier = Modifier353            .fillMaxWidth()354            .clickable(onClick = onClick)355            .padding(vertical = 9.dp),356        verticalAlignment = Alignment.CenterVertically,357    ) {358        FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)359        Column(modifier = Modifier.padding(start = 12.dp)) {360            Text(361                text = entry.title,362                color = peachColors.T0,363                fontFamily = PeachType.Body,364                fontSize = 15.sp,365                maxLines = 1,366                overflow = TextOverflow.Ellipsis,367            )368            val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""369            if (sub.isNotBlank()) {370                Text(371                    text = sub,372                    color = peachColors.T2,373                    fontFamily = PeachType.Mono,374                    fontSize = 11.sp,375                    maxLines = 1,376                    overflow = TextOverflow.Ellipsis,377                )378            }379        }380    }381}