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.BuildConfig48import com.peachpasswords.MainActivity49import com.peachpasswords.shared.extractDomain50import com.peachpasswords.ui.lock.BiometricKeyStore51import com.peachpasswords.ui.lock.Biometrics52import com.peachpasswords.ui.components.FaviconInitials53import com.peachpasswords.ui.components.QaFlags54import com.peachpasswords.ui.components.SecureWindowPolicy55import com.peachpasswords.ui.theme.ACCENT_COLORS56import com.peachpasswords.ui.theme.DEFAULT_ACCENT57import com.peachpasswords.ui.theme.LocalPeachAccent58import com.peachpasswords.ui.theme.peachColors59import com.peachpasswords.ui.theme.PeachTheme60import com.peachpasswords.ui.theme.PeachType61import com.peachpasswords.vault.UnlockResult62import com.peachpasswords.vault.VaultEntry63import com.peachpasswords.vault.VaultRepository64import dagger.hilt.android.AndroidEntryPoint65import kotlinx.coroutines.delay66import kotlinx.coroutines.launch67import javax.inject.Inject6869707172737475767778798081828384858687888990@AndroidEntryPoint91class AutofillEntryPickerActivity : FragmentActivity() {9293 @Inject lateinit var vault: VaultRepository94 private var debugSecureLease = false9596 @OptIn(ExperimentalMaterial3Api::class)97 override fun onCreate(savedInstanceState: Bundle?) {98 super.onCreate(savedInstanceState)99100 101 102 103 if (!BuildConfig.DEBUG) {104 SecureWindowPolicy.installRelease(window)105 } else if (!qaSecureCaptureBypass()) {106 107 108 109 SecureWindowPolicy.acquire(window)110 debugSecureLease = true111 }112113 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)114 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)115 116 117 118 119 val rawParsed = pickerParsedFromInputs(120 origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),121 isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),122 callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),123 verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),124 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),125 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),126 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),127 )128 val parsed = authenticateAutofillOrigin(this, rawParsed)129 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT130131 setContent {132 PeachTheme(accent = accent) {133 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)134 val scope = rememberCoroutineScope()135 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }136 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }137 var error by remember { mutableStateOf<String?>(null) }138 var busy by remember { mutableStateOf(false) }139140 141 142 val allEntries = remember(unlocked) {143 if (unlocked) vault.getEntries() else emptyList()144 }145 val matchedEntries = remember(unlocked, allEntries) {146 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()147 }148149 150 151 152 LaunchedEffect(unlocked) {153 if (!unlocked && canBiometric()) {154 tryBiometric(155 onUnlocked = {156 unlocked = true157 unlockedEpoch = vault.unlockedSessionEpoch()158 },159 onFail = { },160 )161 }162 }163164 ModalBottomSheet(165 onDismissRequest = { cancel() },166 sheetState = sheetState,167 containerColor = peachColors.S1,168 dragHandle = { AccentDragHandle() },169 ) {170 PickerBody(171 origin = parsed.origin,172 originTrust = parsed.originTrust,173 typedIdentity = typedUsername ?: typedEmail,174 unlocked = unlocked,175 matchedEntries = matchedEntries,176 allEntries = allEntries,177 error = error,178 busy = busy,179 canBiometric = canBiometric(),180 onBiometric = {181 tryBiometric(182 onUnlocked = {183 unlocked = true184 unlockedEpoch = vault.unlockedSessionEpoch()185 },186 onFail = {},187 )188 },189 onUnlock = { password ->190 busy = true191 error = null192 scope.launch {193 when (vault.unlock(password)) {194 is UnlockResult.Success -> {195 unlocked = true196 unlockedEpoch = vault.unlockedSessionEpoch()197 }198 UnlockResult.WrongPassword -> error = "Wrong master password."199 else -> error = "Couldn't unlock."200 }201 busy = false202 }203 },204 onPick = { entry, confirmedTarget ->205 val epoch = unlockedEpoch206 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {207 cancel()208 } else {209 returnDataset(parsed, entry, confirmedTarget)210 }211 },212 onCreateNew = { confirmedTarget ->213 launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget)214 },215 )216 }217 }218 }219 }220221 override fun onDestroy() {222 if (debugSecureLease) {223 SecureWindowPolicy.release(window)224 debugSecureLease = false225 }226 super.onDestroy()227 }228229 private fun qaSecureCaptureBypass(): Boolean =230 BuildConfig.DEBUG && (231 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||232 QaFlags.disableSecureCaptureForQa233 )234235 private fun canBiometric(): Boolean =236 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)237238 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {239 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()240 Biometrics.authenticateCrypto(241 activity = this,242 title = "Unlock Peach",243 subtitle = "Fill your saved details",244 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),245 onSuccess = { result ->246 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()247 248 249 250 251 lifecycleScope.launch {252 253 254 255 var key: ByteArray? = null256 try {257 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)258 if (key == null) return@launch onFail()259 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()260 } finally {261 key?.let(com.peachpasswords.vault.VaultCrypto::zero)262 }263 }264 },265 onFail = onFail,266 )267 }268269 private fun returnDataset(270 parsed: ParsedStructure,271 entry: VaultEntry,272 confirmedTarget: String?,273 ) {274 if (275 parsed.originTrust == AutofillOriginTrust.UNKNOWN &&276 AutofillResponseBuilder.matchEntries(listOf(entry), confirmedTarget).hits.isEmpty()277 ) {278 cancel()279 return280 }281 val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)282 if (dataset == null) { cancel(); return }283 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)284 setResult(RESULT_OK, reply)285 finish()286 }287288 289290291292293294 private fun launchNewEntry(295 parsed: ParsedStructure,296 typedUsername: String?,297 typedEmail: String?,298 confirmedTarget: String?,299 ) {300 val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {301 confirmedTarget?.let(::extractDomain) ?: return302 } else {303 parsed.origin304 }305 val intent = Intent(this, MainActivity::class.java).apply {306 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)307 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)308 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)309 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)310 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)311 }312 startActivity(intent)313 cancel()314 }315316 private fun cancel() {317 setResult(RESULT_CANCELED)318 finish()319 }320321 @Suppress("DEPRECATION")322 private fun intentAutofillId(key: String): AutofillId? =323 if (android.os.Build.VERSION.SDK_INT >= 33) {324 intent.getParcelableExtra(key, AutofillId::class.java)325 } else {326 intent.getParcelableExtra(key)327 }328329 private companion object {330 const val MAX_ROWS = 12331 }332}333334335336337338339340341internal fun pickerParsedFromInputs(342 origin: String?,343 isWebOrigin: Boolean,344 callerPackage: String?,345 verifiedDomains: List<String>,346 usernameId: AutofillId? = null,347 passwordId: AutofillId? = null,348 otpId: AutofillId? = null,349): ParsedStructure = ParsedStructure(350 usernameId = usernameId,351 passwordId = passwordId,352 otpId = otpId,353 webDomain = origin.takeIf { isWebOrigin },354 packageName = if (isWebOrigin) callerPackage else origin,355 356 originTrust = AutofillOriginTrust.UNKNOWN,357 verifiedDomains = verifiedDomains,358)359360internal fun autofillPickerEntries(361 entries: List<VaultEntry>,362 parsed: ParsedStructure,363 limit: Int,364): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)365366367368369370371internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {372 if (query.isEmpty()) return true373 val needle = query.lowercase()374 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)375 .any { it.lowercase().contains(needle) }376}377378@Composable379private fun AccentDragHandle() {380 val accent = LocalPeachAccent.current381 Row(382 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),383 horizontalArrangement = Arrangement.Center,384 ) {385 Box(386 modifier = Modifier387 .size(width = 32.dp, height = 4.dp)388 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),389 )390 }391}392393@Composable394private fun PickerBody(395 origin: String?,396 originTrust: AutofillOriginTrust,397 typedIdentity: String?,398 unlocked: Boolean,399 matchedEntries: List<VaultEntry>,400 allEntries: List<VaultEntry>,401 error: String?,402 busy: Boolean,403 canBiometric: Boolean,404 onBiometric: () -> Unit,405 onUnlock: (String) -> Unit,406 onPick: (VaultEntry, String?) -> Unit,407 onCreateNew: (String?) -> Unit,408) {409 val accent = LocalPeachAccent.current410 val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN411 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {412 Text(413 text = "peach",414 color = accent,415 fontFamily = PeachType.Display,416 fontStyle = FontStyle.Italic,417 fontSize = 15.sp,418 )419 Text(420 text = when {421 originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"422 origin.isNullOrBlank() -> "your vault"423 else -> "Suggested for $origin"424 },425 color = peachColors.T2,426 fontFamily = PeachType.Mono,427 fontSize = 9.sp,428 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),429 )430431 if (originTrust == AutofillOriginTrust.UNKNOWN) {432 Box(433 modifier = Modifier434 .fillMaxWidth()435 .padding(bottom = 14.dp)436 .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))437 .padding(12.dp),438 ) {439 Text(440 text = "Peach couldn't verify this app or site. Enter the site domain yourself; only exact domain matches can be filled.",441 color = peachColors.Breach,442 fontFamily = PeachType.Body,443 fontSize = 12.sp,444 )445 }446 }447448 if (!unlocked) {449 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)450 } else {451 452 453 var rawQuery by remember { mutableStateOf("") }454 var debouncedQuery by remember { mutableStateOf("") }455 LaunchedEffect(rawQuery) {456 delay(SEARCH_DEBOUNCE_MS)457 debouncedQuery = rawQuery458 }459 val confirmedTarget = if (untrustedOrigin) {460 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain)461 } else {462 null463 }464 SearchField(465 value = rawQuery,466 onValueChange = { rawQuery = it },467 placeholder = when {468 untrustedOrigin -> "enter site domain, e.g. example.com"469 matchedEntries.isNotEmpty() -> "search for another entry…"470 else -> "search vault…"471 },472 )473474 val visibleEntries = when {475 untrustedOrigin && confirmedTarget == null -> emptyList()476 untrustedOrigin -> AutofillResponseBuilder477 .matchEntries(allEntries, confirmedTarget)478 .hits479 debouncedQuery.isBlank() -> matchedEntries480 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }481 }482 val showSearchEmpty = debouncedQuery.isNotBlank() &&483 visibleEntries.isEmpty() &&484 (!untrustedOrigin || confirmedTarget != null)485486 val emptyMessage = when {487 untrustedOrigin && debouncedQuery.isBlank() ->488 "Type the site domain to choose a saved login."489 untrustedOrigin && confirmedTarget == null ->490 "Enter a full public domain such as example.com."491 untrustedOrigin && visibleEntries.isEmpty() ->492 "No saved entries match $confirmedTarget."493 matchedEntries.isEmpty() && debouncedQuery.isBlank() ->494 "No saved entries match this site."495 showSearchEmpty ->496 "No entries match \"${debouncedQuery.trim()}\"."497 else -> null498 }499 if (emptyMessage != null) {500 Text(501 text = emptyMessage,502 color = peachColors.T1,503 fontFamily = PeachType.Body,504 fontSize = 13.sp,505 modifier = Modifier.padding(vertical = 6.dp),506 )507 }508509 if (visibleEntries.isNotEmpty()) {510 LazyColumn {511 items(visibleEntries, key = { it.id }) { entry ->512 PickerRow(entry, onClick = { onPick(entry, confirmedTarget) })513 }514 }515 }516517 val createOrigin = if (untrustedOrigin) confirmedTarget else origin518 if (!untrustedOrigin || createOrigin != null) {519 CreateNewRow(520 origin = createOrigin,521 typedIdentity = typedIdentity,522 onClick = { onCreateNew(confirmedTarget) },523 )524 }525 }526 }527}528529@Composable530private fun SearchField(531 value: String,532 onValueChange: (String) -> Unit,533 placeholder: String,534) {535 val accent = LocalPeachAccent.current536 BasicTextField(537 value = value,538 onValueChange = onValueChange,539 singleLine = true,540 textStyle = TextStyle(541 color = peachColors.T0,542 fontFamily = PeachType.Mono,543 fontSize = 14.sp,544 ),545 cursorBrush = SolidColor(accent),546 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),547 keyboardActions = KeyboardActions(onSearch = { }),548 modifier = Modifier549 .fillMaxWidth()550 .background(peachColors.S2, RoundedCornerShape(8.dp))551 .padding(horizontal = 12.dp, vertical = 10.dp),552 decorationBox = { inner ->553 Box(modifier = Modifier.fillMaxWidth()) {554 if (value.isEmpty()) {555 Text(556 text = placeholder,557 color = peachColors.T2,558 fontFamily = PeachType.Mono,559 fontSize = 12.sp,560 )561 }562 inner()563 }564 },565 )566}567568@Composable569private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {570 val accent = LocalPeachAccent.current571 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"572 else "Create new entry"573 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }574 Row(575 modifier = Modifier576 .fillMaxWidth()577 .clickable(onClick = onClick)578 .padding(vertical = 10.dp),579 verticalAlignment = Alignment.CenterVertically,580 ) {581 Box(582 modifier = Modifier583 .size(34.dp)584 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),585 contentAlignment = Alignment.Center,586 ) {587 Text(588 text = "+",589 color = accent,590 fontFamily = PeachType.Display,591 fontStyle = FontStyle.Italic,592 fontSize = 18.sp,593 )594 }595 Column(modifier = Modifier.padding(start = 12.dp)) {596 Text(597 text = title,598 color = accent,599 fontFamily = PeachType.Body,600 fontSize = 14.sp,601 maxLines = 1,602 overflow = TextOverflow.Ellipsis,603 )604 if (subtitle != null) {605 Text(606 text = "prefill: $subtitle",607 color = peachColors.T2,608 fontFamily = PeachType.Mono,609 fontSize = 10.sp,610 maxLines = 1,611 overflow = TextOverflow.Ellipsis,612 )613 }614 }615 }616}617618@Composable619private fun UnlockBody(620 error: String?,621 busy: Boolean,622 canBiometric: Boolean,623 onBiometric: () -> Unit,624 onUnlock: (String) -> Unit,625) {626 val accent = LocalPeachAccent.current627 var password by remember { mutableStateOf("") }628629 Text(630 text = "Unlock to fill",631 color = peachColors.T0,632 fontFamily = PeachType.Body,633 fontSize = 15.sp,634 modifier = Modifier.padding(bottom = 10.dp),635 )636 BasicTextField(637 value = password,638 onValueChange = { password = it },639 singleLine = true,640 enabled = !busy,641 textStyle = TextStyle(642 color = peachColors.T0,643 fontFamily = PeachType.Mono,644 fontSize = 16.sp,645 ),646 cursorBrush = SolidColor(accent),647 visualTransformation = PasswordVisualTransformation(),648 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),649 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),650 modifier = Modifier651 .fillMaxWidth()652 .background(peachColors.S2, RoundedCornerShape(8.dp))653 .padding(horizontal = 12.dp, vertical = 12.dp),654 )655 if (error != null) {656 Text(657 text = error,658 color = peachColors.Breach,659 fontFamily = PeachType.Body,660 fontSize = 12.sp,661 modifier = Modifier.padding(top = 8.dp),662 )663 }664 Row(665 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),666 horizontalArrangement = Arrangement.spacedBy(20.dp),667 verticalAlignment = Alignment.CenterVertically,668 ) {669 if (canBiometric) {670 Text(671 text = "Use biometrics",672 color = accent,673 fontFamily = PeachType.Mono,674 fontSize = 13.sp,675 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),676 )677 }678 Text(679 text = if (busy) "Unlocking…" else "Unlock",680 color = if (password.isEmpty() || busy) peachColors.T2 else accent,681 fontFamily = PeachType.Mono,682 fontSize = 13.sp,683 modifier = Modifier684 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }685 .padding(vertical = 8.dp),686 )687 }688}689690@Composable691private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {692 Row(693 modifier = Modifier694 .fillMaxWidth()695 .clickable(onClick = onClick)696 .padding(vertical = 9.dp),697 verticalAlignment = Alignment.CenterVertically,698 ) {699 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)700 Column(modifier = Modifier.padding(start = 12.dp)) {701 Text(702 text = entry.title,703 color = peachColors.T0,704 fontFamily = PeachType.Body,705 fontSize = 15.sp,706 maxLines = 1,707 overflow = TextOverflow.Ellipsis,708 )709 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""710 if (sub.isNotBlank()) {711 Text(712 text = sub,713 color = peachColors.T2,714 fontFamily = PeachType.Mono,715 fontSize = 11.sp,716 maxLines = 1,717 overflow = TextOverflow.Ellipsis,718 )719 }720 }721 }722}723724725private const val SEARCH_DEBOUNCE_MS = 200L