1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Build5import android.os.Bundle6import android.service.autofill.Dataset7import android.view.autofill.AutofillId8import android.view.autofill.AutofillManager9import androidx.activity.compose.setContent10import androidx.compose.foundation.background11import androidx.compose.foundation.clickable12import androidx.compose.foundation.layout.Arrangement13import androidx.compose.foundation.layout.Box14import androidx.compose.foundation.layout.Column15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.fillMaxWidth17import androidx.compose.foundation.layout.padding18import androidx.compose.foundation.layout.size19import androidx.compose.foundation.lazy.LazyColumn20import androidx.compose.foundation.lazy.items21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.material3.ExperimentalMaterial3Api23import androidx.compose.material3.ModalBottomSheet24import androidx.compose.material3.Text25import androidx.compose.material3.rememberModalBottomSheetState26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.foundation.text.KeyboardActions28import androidx.compose.foundation.text.KeyboardOptions29import androidx.compose.runtime.Composable30import androidx.compose.runtime.CompositionLocalProvider31import androidx.compose.runtime.LaunchedEffect32import androidx.compose.runtime.getValue33import androidx.compose.runtime.mutableStateOf34import androidx.compose.runtime.remember35import androidx.compose.runtime.rememberCoroutineScope36import androidx.compose.runtime.setValue37import androidx.compose.ui.graphics.SolidColor38import androidx.compose.ui.text.TextStyle39import androidx.compose.ui.text.input.ImeAction40import androidx.compose.ui.text.input.KeyboardType41import androidx.compose.ui.text.input.PasswordVisualTransformation42import androidx.compose.ui.Alignment43import androidx.compose.ui.Modifier44import androidx.compose.ui.text.font.FontStyle45import androidx.compose.ui.text.style.TextOverflow46import androidx.compose.ui.unit.dp47import androidx.compose.ui.unit.sp48import androidx.fragment.app.FragmentActivity49import androidx.lifecycle.lifecycleScope50import com.peachpasswords.BuildConfig51import com.peachpasswords.MainActivity52import com.peachpasswords.shared.extractDomain53import com.peachpasswords.ui.lock.BiometricKeyStore54import com.peachpasswords.ui.lock.Biometrics55import com.peachpasswords.ui.components.FaviconInitials56import com.peachpasswords.ui.components.LocalFavicons57import com.peachpasswords.ui.components.LocalFaviconsEnabled58import com.peachpasswords.ui.components.QaFlags59import com.peachpasswords.ui.components.SecureWindowPolicy60import com.peachpasswords.ui.theme.ACCENT_COLORS61import com.peachpasswords.ui.theme.DEFAULT_ACCENT62import com.peachpasswords.ui.theme.LocalPeachAccent63import com.peachpasswords.ui.theme.peachColors64import com.peachpasswords.ui.theme.PeachTheme65import com.peachpasswords.ui.theme.PeachType66import com.peachpasswords.vault.UnlockResult67import com.peachpasswords.vault.VaultEntry68import com.peachpasswords.vault.isCard69import com.peachpasswords.vault.VaultRepository70import dagger.hilt.android.AndroidEntryPoint71import kotlinx.coroutines.delay72import kotlinx.coroutines.launch73import javax.inject.Inject7475767778798081828384858687888990919293949596@AndroidEntryPoint97class AutofillEntryPickerActivity : FragmentActivity() {9899 @Inject lateinit var vault: VaultRepository100 @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons101 private var debugSecureLease = false102103 @OptIn(ExperimentalMaterial3Api::class)104 override fun onCreate(savedInstanceState: Bundle?) {105 super.onCreate(savedInstanceState)106107 108 109 110 if (!BuildConfig.DEBUG) {111 SecureWindowPolicy.setUserSecure(window, true)112 } else if (!qaSecureCaptureBypass()) {113 114 115 116 SecureWindowPolicy.acquire(window)117 debugSecureLease = true118 }119120 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)121 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)122 123 124 125 126 val rawParsed = pickerParsedFromInputs(127 origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),128 isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),129 callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),130 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),131 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),132 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),133 otpDigitBoxIds = if (Build.VERSION.SDK_INT >= 33) {134 intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS, AutofillId::class.java).orEmpty()135 } else {136 @Suppress("DEPRECATION")137 intent.getParcelableArrayListExtra<AutofillId>(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS).orEmpty()138 },139 cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),140 cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),141 cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),142 cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),143 cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),144 cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),145 )146 val parsed = resolveAutofillOrigin(this, rawParsed)147 val settings = vault.settings.value148 val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT149150 setContent {151 CompositionLocalProvider(152 LocalFavicons provides favicons,153 LocalFaviconsEnabled provides settings.faviconsEnabled,154 ) {155 PeachTheme(accent = accent, textSize = settings.textSize) {156 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)157 val scope = rememberCoroutineScope()158 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }159 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }160 var error by remember { mutableStateOf<String?>(null) }161 var busy by remember { mutableStateOf(false) }162163 val allEntries = remember(unlocked) {164 if (unlocked) vault.getEntries() else emptyList()165 }166 val matchedEntries = remember(unlocked, allEntries) {167 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()168 }169170 171 172 val identities = remember(unlocked, allEntries, matchedEntries) {173 if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()174 }175176 177 178 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }179 val pickNow: (VaultEntry) -> Unit = { entry ->180 val epoch = unlockedEpoch181 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()182 else returnDataset(parsed, entry)183 }184185 186 187 188 LaunchedEffect(unlocked) {189 if (!unlocked && canBiometric()) {190 tryBiometric(191 onUnlocked = {192 unlocked = true193 unlockedEpoch = vault.unlockedSessionEpoch()194 },195 onFail = { },196 )197 }198 }199200 ModalBottomSheet(201 onDismissRequest = { cancel() },202 sheetState = sheetState,203 containerColor = peachColors.S1,204 dragHandle = { AccentDragHandle() },205 ) {206 PickerBody(207 origin = parsed.origin,208 typedIdentity = typedUsername ?: typedEmail,209 unlocked = unlocked,210 matchedEntries = matchedEntries,211 allEntries = allEntries,212 rememberOffer = rememberOffer,213 onRemember = { entry, domains ->214 rememberOffer = null215 scope.launch {216 val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry217 runCatching {218 vault.updateEntry(219 current.copy(220 additionalUrls = (current.additionalUrls.orEmpty() +221 domains.map { "https://$it" }).distinct(),222 ),223 )224 }225 pickNow(entry)226 }227 },228 onFillOnce = { entry ->229 rememberOffer = null230 pickNow(entry)231 },232 onRememberDismiss = { rememberOffer = null },233 identities = identities,234 onPickIdentity = { identity ->235 val epoch = unlockedEpoch236 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()237 else finishWithDataset(238 AutofillResponseBuilder.identityDatasetFor(239 this@AutofillEntryPickerActivity, parsed, identity,240 ),241 )242 },243 error = error,244 busy = busy,245 canBiometric = canBiometric(),246 onBiometric = {247 tryBiometric(248 onUnlocked = {249 unlocked = true250 unlockedEpoch = vault.unlockedSessionEpoch()251 },252 onFail = {},253 )254 },255 onUnlock = { password ->256 busy = true257 error = null258 scope.launch {259 when (vault.unlock(password)) {260 is UnlockResult.Success -> {261 unlocked = true262 unlockedEpoch = vault.unlockedSessionEpoch()263 }264 UnlockResult.WrongPassword -> error = "Wrong master password."265 else -> error = "Couldn't unlock."266 }267 busy = false268 }269 },270 onPick = { entry ->271 val offer = rememberOfferDomains(entry, parsed)272 if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer273 },274 onCreateNew = { searchText ->275 launchNewEntry(parsed, typedUsername, typedEmail, searchText)276 },277 )278 }279 }280 }281 }282 }283284 override fun onDestroy() {285 if (debugSecureLease) {286 SecureWindowPolicy.release(window)287 debugSecureLease = false288 }289 super.onDestroy()290 }291292 private fun qaSecureCaptureBypass(): Boolean =293 BuildConfig.DEBUG && (294 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||295 QaFlags.disableSecureCaptureForQa296 )297298 private fun canBiometric(): Boolean =299 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)300301 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {302 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()303 Biometrics.authenticateCrypto(304 activity = this,305 title = "Unlock Peach",306 subtitle = "Fill your saved details",307 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),308 onSuccess = { result ->309 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()310 311 312 313 314 lifecycleScope.launch {315 316 317 318 var key: ByteArray? = null319 try {320 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)321 if (key == null) return@launch onFail()322 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()323 } finally {324 key?.let(com.peachpasswords.vault.VaultCrypto::zero)325 }326 }327 },328 onFail = onFail,329 )330 }331332 private fun returnDataset(333 parsed: ParsedStructure,334 entry: VaultEntry,335 ) {336 val privatePreview = vault.settings.value.hideAutofillPreviews337 val dataset = if (entry.isCard()) {338 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null, privatePreview)339 } else {340 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null, privatePreview)341 }342 finishWithDataset(dataset)343 }344345 private fun finishWithDataset(dataset: Dataset?) {346 if (dataset == null) { cancel(); return }347 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)348 setResult(RESULT_OK, reply)349 finish()350 }351352 353354355356357358 private fun launchNewEntry(359 parsed: ParsedStructure,360 typedUsername: String?,361 typedEmail: String?,362 searchText: String?,363 ) {364 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin365 val intent = Intent(this, MainActivity::class.java).apply {366 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)367 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN, AutofillIntents.issueNewEntryToken())368 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)369 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)370 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)371 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)372 }373 startActivity(intent)374 cancel()375 }376377 private fun cancel() {378 setResult(RESULT_CANCELED)379 finish()380 }381382 @Suppress("DEPRECATION")383 private fun intentAutofillId(key: String): AutofillId? =384 if (android.os.Build.VERSION.SDK_INT >= 33) {385 intent.getParcelableExtra(key, AutofillId::class.java)386 } else {387 intent.getParcelableExtra(key)388 }389390 private companion object {391 const val MAX_ROWS = 12392 }393}394395396397398399400internal fun pickerParsedFromInputs(401 origin: String?,402 isWebOrigin: Boolean,403 callerPackage: String?,404 usernameId: AutofillId? = null,405 passwordId: AutofillId? = null,406 otpId: AutofillId? = null,407 otpDigitBoxIds: List<AutofillId> = emptyList(),408 cardNumberId: AutofillId? = null,409 cardExpMonthId: AutofillId? = null,410 cardExpYearId: AutofillId? = null,411 cardExpDateId: AutofillId? = null,412 cardCvcId: AutofillId? = null,413 cardNameId: AutofillId? = null,414): ParsedStructure = ParsedStructure(415 usernameId = usernameId,416 passwordId = passwordId,417 otpId = otpId,418 cardNumberId = cardNumberId,419 cardExpMonthId = cardExpMonthId,420 cardExpYearId = cardExpYearId,421 cardExpDateId = cardExpDateId,422 cardCvcId = cardCvcId,423 cardNameId = cardNameId,424 otpDigitBoxIds = otpDigitBoxIds,425 webDomain = origin.takeIf { isWebOrigin },426 packageName = if (isWebOrigin) callerPackage else origin,427)428429internal fun autofillPickerEntries(430 entries: List<VaultEntry>,431 parsed: ParsedStructure,432 limit: Int,433): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)434435436437438439440internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {441 if (query.isEmpty()) return true442 val needle = query.lowercase()443 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)444 .any { it.lowercase().contains(needle) }445}446447@Composable448private fun AccentDragHandle() {449 val accent = LocalPeachAccent.current450 Row(451 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),452 horizontalArrangement = Arrangement.Center,453 ) {454 Box(455 modifier = Modifier456 .size(width = 32.dp, height = 4.dp)457 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),458 )459 }460}461462@Composable463private fun PickerBody(464 origin: String?,465 typedIdentity: String?,466 unlocked: Boolean,467 matchedEntries: List<VaultEntry>,468 allEntries: List<VaultEntry>,469 rememberOffer: Pair<VaultEntry, List<String>>?,470 onRemember: (VaultEntry, List<String>) -> Unit,471 onFillOnce: (VaultEntry) -> Unit,472 onRememberDismiss: () -> Unit,473 identities: List<SavedIdentity>,474 onPickIdentity: (SavedIdentity) -> Unit,475 error: String?,476 busy: Boolean,477 canBiometric: Boolean,478 onBiometric: () -> Unit,479 onUnlock: (String) -> Unit,480 onPick: (VaultEntry) -> Unit,481 onCreateNew: (String?) -> Unit,482) {483 val accent = LocalPeachAccent.current484 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {485 Text(486 text = "peach",487 color = accent,488 fontFamily = PeachType.Display,489 fontStyle = FontStyle.Italic,490 fontSize = 15.sp,491 )492 Text(493 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",494 color = peachColors.T2,495 fontFamily = PeachType.Mono,496 fontSize = 9.sp,497 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),498 )499500 if (!unlocked) {501 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)502 } else if (rememberOffer != null) {503 RememberSitePrompt(504 entryTitle = rememberOffer!!.first.title,505 domains = rememberOffer!!.second,506 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },507 onFillOnce = { onFillOnce(rememberOffer!!.first) },508 onBack = onRememberDismiss,509 )510 } else {511 512 513 var rawQuery by remember { mutableStateOf("") }514 var debouncedQuery by remember { mutableStateOf("") }515 LaunchedEffect(rawQuery) {516 delay(SEARCH_DEBOUNCE_MS)517 debouncedQuery = rawQuery518 }519 SearchField(520 value = rawQuery,521 onValueChange = { rawQuery = it },522 placeholder = when {523 matchedEntries.isNotEmpty() -> "search for another entry…"524 else -> "search vault…"525 },526 )527528 val showIdentities =529 matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()530 val visibleEntries = when {531 debouncedQuery.isBlank() -> matchedEntries532 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }533 }534 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()535536 val emptyMessage = when {537 matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->538 "No saved entries match this site."539 showSearchEmpty ->540 "No entries match \"${debouncedQuery.trim()}\"."541 else -> null542 }543 if (emptyMessage != null) {544 Text(545 text = emptyMessage,546 color = peachColors.T1,547 fontFamily = PeachType.Body,548 fontSize = 13.sp,549 modifier = Modifier.padding(vertical = 6.dp),550 )551 }552553 if (showIdentities) {554 IdentitySection(identities = identities, onPick = onPickIdentity)555 }556557 if (visibleEntries.isNotEmpty()) {558 LazyColumn {559 items(visibleEntries, key = { it.id }) { entry ->560 PickerRow(entry, onClick = { onPick(entry) })561 }562 }563 }564565 val createOrigin =566 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin567 CreateNewRow(568 origin = createOrigin,569 typedIdentity = typedIdentity,570 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },571 )572 }573 }574}575576@Composable577private fun SearchField(578 value: String,579 onValueChange: (String) -> Unit,580 placeholder: String,581) {582 val accent = LocalPeachAccent.current583 BasicTextField(584 value = value,585 onValueChange = onValueChange,586 singleLine = true,587 textStyle = TextStyle(588 color = peachColors.T0,589 fontFamily = PeachType.Mono,590 fontSize = 14.sp,591 ),592 cursorBrush = SolidColor(accent),593 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),594 keyboardActions = KeyboardActions(onSearch = { }),595 modifier = Modifier596 .fillMaxWidth()597 .background(peachColors.S2, RoundedCornerShape(8.dp))598 .padding(horizontal = 12.dp, vertical = 10.dp),599 decorationBox = { inner ->600 Box(modifier = Modifier.fillMaxWidth()) {601 if (value.isEmpty()) {602 Text(603 text = placeholder,604 color = peachColors.T2,605 fontFamily = PeachType.Mono,606 fontSize = 12.sp,607 )608 }609 inner()610 }611 },612 )613}614615@Composable616private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {617 val accent = LocalPeachAccent.current618 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"619 else "Create new entry"620 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }621 Row(622 modifier = Modifier623 .fillMaxWidth()624 .clickable(onClick = onClick)625 .padding(vertical = 10.dp),626 verticalAlignment = Alignment.CenterVertically,627 ) {628 Box(629 modifier = Modifier630 .size(34.dp)631 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),632 contentAlignment = Alignment.Center,633 ) {634 Text(635 text = "+",636 color = accent,637 fontFamily = PeachType.Display,638 fontStyle = FontStyle.Italic,639 fontSize = 18.sp,640 )641 }642 Column(modifier = Modifier.padding(start = 12.dp)) {643 Text(644 text = title,645 color = accent,646 fontFamily = PeachType.Body,647 fontSize = 14.sp,648 maxLines = 1,649 overflow = TextOverflow.Ellipsis,650 )651 if (subtitle != null) {652 Text(653 text = "prefill: $subtitle",654 color = peachColors.T2,655 fontFamily = PeachType.Mono,656 fontSize = 10.sp,657 maxLines = 1,658 overflow = TextOverflow.Ellipsis,659 )660 }661 }662 }663}664665666667668669670671@Composable672private fun RememberSitePrompt(673 entryTitle: String,674 domains: List<String>,675 onRemember: () -> Unit,676 onFillOnce: () -> Unit,677 onBack: () -> Unit,678) {679 val accent = LocalPeachAccent.current680 Text(681 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",682 color = peachColors.T0,683 fontFamily = PeachType.Body,684 fontSize = 14.sp,685 )686 Text(687 text = "It will be suggested automatically the next time you fill here.",688 color = peachColors.T2,689 fontFamily = PeachType.Mono,690 fontSize = 10.sp,691 modifier = Modifier.padding(top = 4.dp),692 )693 Row(694 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),695 horizontalArrangement = Arrangement.spacedBy(18.dp),696 verticalAlignment = Alignment.CenterVertically,697 ) {698 Text(699 text = "Remember & fill",700 color = accent,701 fontFamily = PeachType.Mono,702 fontSize = 13.sp,703 modifier = Modifier704 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))705 .clickable(onClick = onRemember)706 .padding(horizontal = 12.dp, vertical = 8.dp),707 )708 Text(709 text = "Just fill",710 color = peachColors.T1,711 fontFamily = PeachType.Mono,712 fontSize = 13.sp,713 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),714 )715 Text(716 text = "Back",717 color = peachColors.T2,718 fontFamily = PeachType.Mono,719 fontSize = 13.sp,720 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),721 )722 }723}724725726727728729730@Composable731private fun IdentitySection(732 identities: List<SavedIdentity>,733 onPick: (SavedIdentity) -> Unit,734) {735 val accent = LocalPeachAccent.current736 var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }737 val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }738739 Text(740 text = "use a saved identity",741 color = peachColors.T2,742 fontFamily = PeachType.Mono,743 fontSize = 9.sp,744 modifier = Modifier.padding(bottom = 8.dp),745 )746 Row(747 horizontalArrangement = Arrangement.spacedBy(8.dp),748 modifier = Modifier.padding(bottom = 10.dp),749 ) {750 for (filterMode in IdentityFilterMode.values()) {751 val active = filterMode == mode752 Text(753 text = filterMode.label,754 color = if (active) peachColors.S1 else peachColors.T2,755 fontFamily = PeachType.Mono,756 fontSize = 10.sp,757 modifier = Modifier758 .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))759 .clickable { mode = filterMode }760 .padding(horizontal = 10.dp, vertical = 5.dp),761 )762 }763 }764 LazyColumn {765 items(visible, key = { it.type.name + ":" + it.value }) { identity ->766 IdentityRow(identity, onClick = { onPick(identity) })767 }768 }769}770771@Composable772private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {773 Column(774 modifier = Modifier775 .fillMaxWidth()776 .clickable(onClick = onClick)777 .padding(vertical = 8.dp),778 ) {779 Text(780 text = identity.value,781 color = peachColors.T0,782 fontFamily = PeachType.Body,783 fontSize = 13.sp,784 maxLines = 1,785 overflow = TextOverflow.Ellipsis,786 )787 val meta = listOfNotNull(788 identity.exampleDomain,789 if (identity.type == IdentityType.EMAIL) "email" else "username",790 ).joinToString(" \u00b7 ")791 Text(792 text = meta,793 color = peachColors.T2,794 fontFamily = PeachType.Mono,795 fontSize = 9.sp,796 maxLines = 1,797 overflow = TextOverflow.Ellipsis,798 )799 }800}801802@Composable803private fun UnlockBody(804 error: String?,805 busy: Boolean,806 canBiometric: Boolean,807 onBiometric: () -> Unit,808 onUnlock: (String) -> Unit,809) {810 val accent = LocalPeachAccent.current811 var password by remember { mutableStateOf("") }812813 Text(814 text = "Unlock to fill",815 color = peachColors.T0,816 fontFamily = PeachType.Body,817 fontSize = 15.sp,818 modifier = Modifier.padding(bottom = 10.dp),819 )820 BasicTextField(821 value = password,822 onValueChange = { password = it },823 singleLine = true,824 enabled = !busy,825 textStyle = TextStyle(826 color = peachColors.T0,827 fontFamily = PeachType.Mono,828 fontSize = 16.sp,829 ),830 cursorBrush = SolidColor(accent),831 visualTransformation = PasswordVisualTransformation(),832 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),833 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),834 modifier = Modifier835 .fillMaxWidth()836 .background(peachColors.S2, RoundedCornerShape(8.dp))837 .padding(horizontal = 12.dp, vertical = 12.dp),838 )839 if (error != null) {840 Text(841 text = error,842 color = peachColors.Breach,843 fontFamily = PeachType.Body,844 fontSize = 12.sp,845 modifier = Modifier.padding(top = 8.dp),846 )847 }848 Row(849 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),850 horizontalArrangement = Arrangement.spacedBy(20.dp),851 verticalAlignment = Alignment.CenterVertically,852 ) {853 if (canBiometric) {854 Text(855 text = "Use biometrics",856 color = accent,857 fontFamily = PeachType.Mono,858 fontSize = 13.sp,859 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),860 )861 }862 Text(863 text = if (busy) "Unlocking…" else "Unlock",864 color = if (password.isEmpty() || busy) peachColors.T2 else accent,865 fontFamily = PeachType.Mono,866 fontSize = 13.sp,867 modifier = Modifier868 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }869 .padding(vertical = 8.dp),870 )871 }872}873874@Composable875private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {876 Row(877 modifier = Modifier878 .fillMaxWidth()879 .clickable(onClick = onClick)880 .padding(vertical = 9.dp),881 verticalAlignment = Alignment.CenterVertically,882 ) {883 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)884 Column(modifier = Modifier.padding(start = 12.dp)) {885 Text(886 text = entry.title,887 color = peachColors.T0,888 fontFamily = PeachType.Body,889 fontSize = 15.sp,890 maxLines = 1,891 overflow = TextOverflow.Ellipsis,892 )893 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""894 if (sub.isNotBlank()) {895 Text(896 text = sub,897 color = peachColors.T2,898 fontFamily = PeachType.Mono,899 fontSize = 11.sp,900 maxLines = 1,901 overflow = TextOverflow.Ellipsis,902 )903 }904 }905 }906}907908909private const val SEARCH_DEBOUNCE_MS = 200L