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.Inject6768697071727374757677787980818283848586878889@AndroidEntryPoint90class AutofillEntryPickerActivity : FragmentActivity() {9192 @Inject lateinit var vault: VaultRepository9394 @OptIn(ExperimentalMaterial3Api::class)95 override fun onCreate(savedInstanceState: Bundle?) {96 super.onCreate(savedInstanceState)9798 99 100 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 unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }134 var error by remember { mutableStateOf<String?>(null) }135 var busy by remember { mutableStateOf(false) }136137 138 139 val allEntries = remember(unlocked) {140 if (unlocked) vault.getEntries() else emptyList()141 }142 val matchedEntries = remember(unlocked, allEntries) {143 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()144 }145146 147 148 149 LaunchedEffect(unlocked) {150 if (!unlocked && canBiometric()) {151 tryBiometric(152 onUnlocked = {153 unlocked = true154 unlockedEpoch = vault.unlockedSessionEpoch()155 },156 onFail = { },157 )158 }159 }160161 ModalBottomSheet(162 onDismissRequest = { cancel() },163 sheetState = sheetState,164 containerColor = peachColors.S1,165 dragHandle = { AccentDragHandle() },166 ) {167 PickerBody(168 origin = parsed.origin,169 originTrust = parsed.originTrust,170 typedIdentity = typedUsername ?: typedEmail,171 unlocked = unlocked,172 matchedEntries = matchedEntries,173 allEntries = allEntries,174 error = error,175 busy = busy,176 canBiometric = canBiometric(),177 onBiometric = {178 tryBiometric(179 onUnlocked = {180 unlocked = true181 unlockedEpoch = vault.unlockedSessionEpoch()182 },183 onFail = {},184 )185 },186 onUnlock = { password ->187 busy = true188 error = null189 scope.launch {190 when (vault.unlock(password)) {191 is UnlockResult.Success -> {192 unlocked = true193 unlockedEpoch = vault.unlockedSessionEpoch()194 }195 UnlockResult.WrongPassword -> error = "Wrong master password."196 else -> error = "Couldn't unlock."197 }198 busy = false199 }200 },201 onPick = { entry, confirmedTarget ->202 val epoch = unlockedEpoch203 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {204 cancel()205 } else {206 returnDataset(parsed, entry, confirmedTarget)207 }208 },209 onCreateNew = { confirmedTarget ->210 launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget)211 },212 )213 }214 }215 }216 }217218 private fun canBiometric(): Boolean =219 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)220221 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {222 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()223 Biometrics.authenticateCrypto(224 activity = this,225 title = "Unlock Peach",226 subtitle = "Fill your saved details",227 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),228 onSuccess = { result ->229 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()230 231 232 233 234 lifecycleScope.launch {235 236 237 238 var key: ByteArray? = null239 try {240 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)241 if (key == null) return@launch onFail()242 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()243 } finally {244 key?.let(com.peachpasswords.vault.VaultCrypto::zero)245 }246 }247 },248 onFail = onFail,249 )250 }251252 private fun returnDataset(253 parsed: ParsedStructure,254 entry: VaultEntry,255 confirmedTarget: String?,256 ) {257 if (258 parsed.originTrust == AutofillOriginTrust.UNKNOWN &&259 AutofillResponseBuilder.matchEntries(listOf(entry), confirmedTarget).hits.isEmpty()260 ) {261 cancel()262 return263 }264 val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)265 if (dataset == null) { cancel(); return }266 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)267 setResult(RESULT_OK, reply)268 finish()269 }270271 272273274275276277 private fun launchNewEntry(278 parsed: ParsedStructure,279 typedUsername: String?,280 typedEmail: String?,281 confirmedTarget: String?,282 ) {283 val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {284 confirmedTarget?.let(::extractDomain) ?: return285 } else {286 parsed.origin287 }288 val intent = Intent(this, MainActivity::class.java).apply {289 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)290 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)291 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)292 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)293 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)294 }295 startActivity(intent)296 cancel()297 }298299 private fun cancel() {300 setResult(RESULT_CANCELED)301 finish()302 }303304 @Suppress("DEPRECATION")305 private fun intentAutofillId(key: String): AutofillId? =306 if (android.os.Build.VERSION.SDK_INT >= 33) {307 intent.getParcelableExtra(key, AutofillId::class.java)308 } else {309 intent.getParcelableExtra(key)310 }311312 private companion object {313 const val MAX_ROWS = 12314 }315}316317internal fun autofillPickerEntries(318 entries: List<VaultEntry>,319 parsed: ParsedStructure,320 limit: Int,321): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)322323324325326327328internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {329 if (query.isEmpty()) return true330 val needle = query.lowercase()331 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)332 .any { it.lowercase().contains(needle) }333}334335@Composable336private fun AccentDragHandle() {337 val accent = LocalPeachAccent.current338 Row(339 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),340 horizontalArrangement = Arrangement.Center,341 ) {342 Box(343 modifier = Modifier344 .size(width = 32.dp, height = 4.dp)345 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),346 )347 }348}349350@Composable351private fun PickerBody(352 origin: String?,353 originTrust: AutofillOriginTrust,354 typedIdentity: String?,355 unlocked: Boolean,356 matchedEntries: List<VaultEntry>,357 allEntries: List<VaultEntry>,358 error: String?,359 busy: Boolean,360 canBiometric: Boolean,361 onBiometric: () -> Unit,362 onUnlock: (String) -> Unit,363 onPick: (VaultEntry, String?) -> Unit,364 onCreateNew: (String?) -> Unit,365) {366 val accent = LocalPeachAccent.current367 val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN368 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {369 Text(370 text = "peach",371 color = accent,372 fontFamily = PeachType.Display,373 fontStyle = FontStyle.Italic,374 fontSize = 15.sp,375 )376 Text(377 text = when {378 originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"379 origin.isNullOrBlank() -> "your vault"380 else -> "Suggested for $origin"381 },382 color = peachColors.T2,383 fontFamily = PeachType.Mono,384 fontSize = 9.sp,385 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),386 )387388 if (originTrust == AutofillOriginTrust.UNKNOWN) {389 Box(390 modifier = Modifier391 .fillMaxWidth()392 .padding(bottom = 14.dp)393 .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))394 .padding(12.dp),395 ) {396 Text(397 text = "Peach couldn't verify this app or site. Enter the site domain yourself; only exact domain matches can be filled.",398 color = peachColors.Breach,399 fontFamily = PeachType.Body,400 fontSize = 12.sp,401 )402 }403 }404405 if (!unlocked) {406 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)407 } else {408 409 410 var rawQuery by remember { mutableStateOf("") }411 var debouncedQuery by remember { mutableStateOf("") }412 LaunchedEffect(rawQuery) {413 delay(SEARCH_DEBOUNCE_MS)414 debouncedQuery = rawQuery415 }416 val confirmedTarget = if (untrustedOrigin) {417 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain)418 } else {419 null420 }421 SearchField(422 value = rawQuery,423 onValueChange = { rawQuery = it },424 placeholder = when {425 untrustedOrigin -> "enter site domain, e.g. example.com"426 matchedEntries.isNotEmpty() -> "search for another entry…"427 else -> "search vault…"428 },429 )430431 val visibleEntries = when {432 untrustedOrigin && confirmedTarget == null -> emptyList()433 untrustedOrigin -> AutofillResponseBuilder434 .matchEntries(allEntries, confirmedTarget)435 .hits436 debouncedQuery.isBlank() -> matchedEntries437 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }438 }439 val showSearchEmpty = debouncedQuery.isNotBlank() &&440 visibleEntries.isEmpty() &&441 (!untrustedOrigin || confirmedTarget != null)442443 val emptyMessage = when {444 untrustedOrigin && debouncedQuery.isBlank() ->445 "Type the site domain to choose a saved login."446 untrustedOrigin && confirmedTarget == null ->447 "Enter a full public domain such as example.com."448 untrustedOrigin && visibleEntries.isEmpty() ->449 "No saved entries match $confirmedTarget."450 matchedEntries.isEmpty() && debouncedQuery.isBlank() ->451 "No saved entries match this site."452 showSearchEmpty ->453 "No entries match \"${debouncedQuery.trim()}\"."454 else -> null455 }456 if (emptyMessage != null) {457 Text(458 text = emptyMessage,459 color = peachColors.T1,460 fontFamily = PeachType.Body,461 fontSize = 13.sp,462 modifier = Modifier.padding(vertical = 6.dp),463 )464 }465466 if (visibleEntries.isNotEmpty()) {467 LazyColumn {468 items(visibleEntries, key = { it.id }) { entry ->469 PickerRow(entry, onClick = { onPick(entry, confirmedTarget) })470 }471 }472 }473474 val createOrigin = if (untrustedOrigin) confirmedTarget else origin475 if (!untrustedOrigin || createOrigin != null) {476 CreateNewRow(477 origin = createOrigin,478 typedIdentity = typedIdentity,479 onClick = { onCreateNew(confirmedTarget) },480 )481 }482 }483 }484}485486@Composable487private fun SearchField(488 value: String,489 onValueChange: (String) -> Unit,490 placeholder: String,491) {492 val accent = LocalPeachAccent.current493 BasicTextField(494 value = value,495 onValueChange = onValueChange,496 singleLine = true,497 textStyle = TextStyle(498 color = peachColors.T0,499 fontFamily = PeachType.Mono,500 fontSize = 14.sp,501 ),502 cursorBrush = SolidColor(accent),503 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),504 keyboardActions = KeyboardActions(onSearch = { }),505 modifier = Modifier506 .fillMaxWidth()507 .background(peachColors.S2, RoundedCornerShape(8.dp))508 .padding(horizontal = 12.dp, vertical = 10.dp),509 decorationBox = { inner ->510 Box(modifier = Modifier.fillMaxWidth()) {511 if (value.isEmpty()) {512 Text(513 text = placeholder,514 color = peachColors.T2,515 fontFamily = PeachType.Mono,516 fontSize = 12.sp,517 )518 }519 inner()520 }521 },522 )523}524525@Composable526private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {527 val accent = LocalPeachAccent.current528 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"529 else "Create new entry"530 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }531 Row(532 modifier = Modifier533 .fillMaxWidth()534 .clickable(onClick = onClick)535 .padding(vertical = 10.dp),536 verticalAlignment = Alignment.CenterVertically,537 ) {538 Box(539 modifier = Modifier540 .size(34.dp)541 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),542 contentAlignment = Alignment.Center,543 ) {544 Text(545 text = "+",546 color = accent,547 fontFamily = PeachType.Display,548 fontStyle = FontStyle.Italic,549 fontSize = 18.sp,550 )551 }552 Column(modifier = Modifier.padding(start = 12.dp)) {553 Text(554 text = title,555 color = accent,556 fontFamily = PeachType.Body,557 fontSize = 14.sp,558 maxLines = 1,559 overflow = TextOverflow.Ellipsis,560 )561 if (subtitle != null) {562 Text(563 text = "prefill: $subtitle",564 color = peachColors.T2,565 fontFamily = PeachType.Mono,566 fontSize = 10.sp,567 maxLines = 1,568 overflow = TextOverflow.Ellipsis,569 )570 }571 }572 }573}574575@Composable576private fun UnlockBody(577 error: String?,578 busy: Boolean,579 canBiometric: Boolean,580 onBiometric: () -> Unit,581 onUnlock: (String) -> Unit,582) {583 val accent = LocalPeachAccent.current584 var password by remember { mutableStateOf("") }585586 Text(587 text = "Unlock to fill",588 color = peachColors.T0,589 fontFamily = PeachType.Body,590 fontSize = 15.sp,591 modifier = Modifier.padding(bottom = 10.dp),592 )593 BasicTextField(594 value = password,595 onValueChange = { password = it },596 singleLine = true,597 enabled = !busy,598 textStyle = TextStyle(599 color = peachColors.T0,600 fontFamily = PeachType.Mono,601 fontSize = 16.sp,602 ),603 cursorBrush = SolidColor(accent),604 visualTransformation = PasswordVisualTransformation(),605 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),606 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),607 modifier = Modifier608 .fillMaxWidth()609 .background(peachColors.S2, RoundedCornerShape(8.dp))610 .padding(horizontal = 12.dp, vertical = 12.dp),611 )612 if (error != null) {613 Text(614 text = error,615 color = peachColors.Breach,616 fontFamily = PeachType.Body,617 fontSize = 12.sp,618 modifier = Modifier.padding(top = 8.dp),619 )620 }621 Row(622 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),623 horizontalArrangement = Arrangement.spacedBy(20.dp),624 verticalAlignment = Alignment.CenterVertically,625 ) {626 if (canBiometric) {627 Text(628 text = "Use biometrics",629 color = accent,630 fontFamily = PeachType.Mono,631 fontSize = 13.sp,632 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),633 )634 }635 Text(636 text = if (busy) "Unlocking…" else "Unlock",637 color = if (password.isEmpty() || busy) peachColors.T2 else accent,638 fontFamily = PeachType.Mono,639 fontSize = 13.sp,640 modifier = Modifier641 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }642 .padding(vertical = 8.dp),643 )644 }645}646647@Composable648private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {649 Row(650 modifier = Modifier651 .fillMaxWidth()652 .clickable(onClick = onClick)653 .padding(vertical = 9.dp),654 verticalAlignment = Alignment.CenterVertically,655 ) {656 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)657 Column(modifier = Modifier.padding(start = 12.dp)) {658 Text(659 text = entry.title,660 color = peachColors.T0,661 fontFamily = PeachType.Body,662 fontSize = 15.sp,663 maxLines = 1,664 overflow = TextOverflow.Ellipsis,665 )666 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""667 if (sub.isNotBlank()) {668 Text(669 text = sub,670 color = peachColors.T2,671 fontFamily = PeachType.Mono,672 fontSize = 11.sp,673 maxLines = 1,674 overflow = TextOverflow.Ellipsis,675 )676 }677 }678 }679}680681682private const val SEARCH_DEBOUNCE_MS = 200L