package com.peachpasswords.autofill import android.content.Intent import android.os.Bundle import android.view.autofill.AutofillId import android.view.autofill.AutofillManager import androidx.activity.compose.setContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import com.peachpasswords.shared.extractDomain import com.peachpasswords.ui.lock.BiometricKeyStore import com.peachpasswords.ui.lock.Biometrics import com.peachpasswords.ui.components.FaviconInitials import com.peachpasswords.ui.theme.ACCENT_COLORS import com.peachpasswords.ui.theme.DEFAULT_ACCENT import com.peachpasswords.ui.theme.LocalPeachAccent import com.peachpasswords.ui.theme.peachColors import com.peachpasswords.ui.theme.PeachTheme import com.peachpasswords.ui.theme.PeachType import com.peachpasswords.vault.UnlockResult import com.peachpasswords.vault.VaultEntry import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import javax.inject.Inject /** * Branded bottom sheet shown when the user taps "Search Peach…"/"Unlock Peach" * in the system autofill drop-down. Lists matching vault entries using the same * row vocabulary as the vault list; picking one returns a filled * [android.service.autofill.Dataset] to the framework via * [AutofillManager.EXTRA_AUTHENTICATION_RESULT]. * * Unlocking happens HERE, in the sheet. It used to send the user to * MainActivity and return RESULT_CANCELED, which produced a loop with no exit: * the framework treats a cancelled auth as "not filled" and re-offers the same * prompt, so the user unlocked the app, came back to a field that still asked * them to unlock, and had no way to tell that anything had happened. Autofill * is a modal interruption of someone else's app — leaving it to go somewhere * else is the one thing it must not do. */ @AndroidEntryPoint class AutofillEntryPickerActivity : FragmentActivity() { @Inject lateinit var vault: VaultRepository @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN) val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false) val parsed = ParsedStructure( usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID), passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID), otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID), webDomain = origin.takeIf { isWebOrigin }, packageName = origin.takeUnless { isWebOrigin }, ) val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT setContent { PeachTheme(accent = accent) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() var unlocked by remember { mutableStateOf(vault.isUnlocked.value) } var error by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } val entries = remember(unlocked) { if (unlocked) autofillPickerEntries(vault.getEntries(), parsed, MAX_ROWS) else emptyList() } // Offer biometrics immediately — it is the whole point of // unlocking here rather than in the app, and a prompt the user // can dismiss straight onto the password field costs nothing. LaunchedEffect(unlocked) { if (!unlocked && canBiometric()) { tryBiometric( onUnlocked = { unlocked = true }, onFail = { }, ) } } ModalBottomSheet( onDismissRequest = { cancel() }, sheetState = sheetState, containerColor = peachColors.S1, dragHandle = { AccentDragHandle() }, ) { PickerBody( origin = parsed.origin, unlocked = unlocked, entries = entries, error = error, busy = busy, canBiometric = canBiometric(), onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) }, onUnlock = { password -> busy = true error = null scope.launch { when (vault.unlock(password)) { is UnlockResult.Success -> unlocked = true UnlockResult.WrongPassword -> error = "Wrong master password." else -> error = "Couldn't unlock." } busy = false } }, onPick = { returnDataset(parsed, it) }, ) } } } } private fun canBiometric(): Boolean = Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this) private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) { val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail() Biometrics.authenticateCrypto( activity = this, title = "Unlock Peach", subtitle = "Fill your saved details", crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher), onSuccess = { result -> val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail() val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail() lifecycleScope.launch { if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail() } }, onFail = onFail, ) } private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) { val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null) if (dataset == null) { cancel(); return } val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) setResult(RESULT_OK, reply) finish() } private fun cancel() { setResult(RESULT_CANCELED) finish() } @Suppress("DEPRECATION") private fun intentAutofillId(key: String): AutofillId? = if (android.os.Build.VERSION.SDK_INT >= 33) { intent.getParcelableExtra(key, AutofillId::class.java) } else { intent.getParcelableExtra(key) } private companion object { const val MAX_ROWS = 12 } } internal fun autofillPickerEntries( entries: List, parsed: ParsedStructure, limit: Int, ): List = when { parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit) parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit) else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit) } @Composable private fun AccentDragHandle() { val accent = LocalPeachAccent.current Row( modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp), horizontalArrangement = Arrangement.Center, ) { Box( modifier = Modifier .size(width = 32.dp, height = 4.dp) .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)), ) } } @Composable private fun PickerBody( origin: String?, unlocked: Boolean, entries: List, error: String?, busy: Boolean, canBiometric: Boolean, onBiometric: () -> Unit, onUnlock: (String) -> Unit, onPick: (VaultEntry) -> Unit, ) { val accent = LocalPeachAccent.current Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) { Text( text = "peach", color = accent, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 15.sp, ) Text( text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin", color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 9.sp, modifier = Modifier.padding(top = 2.dp, bottom = 14.dp), ) if (!unlocked) { UnlockBody(error, busy, canBiometric, onBiometric, onUnlock) } else if (entries.isEmpty()) { Text( text = "No saved entries match this site.", color = peachColors.T1, fontFamily = PeachType.Body, fontSize = 14.sp, ) } else { LazyColumn { items(entries, key = { it.id }) { entry -> PickerRow(entry, onClick = { onPick(entry) }) } } } } } @Composable private fun UnlockBody( error: String?, busy: Boolean, canBiometric: Boolean, onBiometric: () -> Unit, onUnlock: (String) -> Unit, ) { val accent = LocalPeachAccent.current var password by remember { mutableStateOf("") } Text( text = "Unlock to fill", color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 15.sp, modifier = Modifier.padding(bottom = 10.dp), ) BasicTextField( value = password, onValueChange = { password = it }, singleLine = true, enabled = !busy, textStyle = TextStyle( color = peachColors.T0, fontFamily = PeachType.Mono, fontSize = 16.sp, ), cursorBrush = SolidColor(accent), visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }), modifier = Modifier .fillMaxWidth() .background(peachColors.S2, RoundedCornerShape(8.dp)) .padding(horizontal = 12.dp, vertical = 12.dp), ) if (error != null) { Text( text = error, color = peachColors.Breach, fontFamily = PeachType.Body, fontSize = 12.sp, modifier = Modifier.padding(top = 8.dp), ) } Row( modifier = Modifier.fillMaxWidth().padding(top = 14.dp), horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically, ) { if (canBiometric) { Text( text = "Use biometrics", color = accent, fontFamily = PeachType.Mono, fontSize = 13.sp, modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp), ) } Text( text = if (busy) "Unlocking…" else "Unlock", color = if (password.isEmpty() || busy) peachColors.T2 else accent, fontFamily = PeachType.Mono, fontSize = 13.sp, modifier = Modifier .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) } .padding(vertical = 8.dp), ) } } @Composable private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, ) { FaviconInitials(source = entry.url ?: entry.title, size = 34.dp) Column(modifier = Modifier.padding(start = 12.dp)) { Text( text = entry.title, color = peachColors.T0, fontFamily = PeachType.Body, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "" if (sub.isNotBlank()) { Text( text = sub, color = peachColors.T2, fontFamily = PeachType.Mono, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } }