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.QaFlags58import com.peachpasswords.ui.components.SecureWindowPolicy59import com.peachpasswords.ui.theme.ACCENT_COLORS60import com.peachpasswords.ui.theme.DEFAULT_ACCENT61import com.peachpasswords.ui.theme.LocalPeachAccent62import com.peachpasswords.ui.theme.peachColors63import com.peachpasswords.ui.theme.PeachTheme64import com.peachpasswords.ui.theme.PeachType65import com.peachpasswords.vault.UnlockResult66import com.peachpasswords.vault.VaultEntry67import com.peachpasswords.vault.isCard68import com.peachpasswords.vault.VaultRepository69import dagger.hilt.android.AndroidEntryPoint70import kotlinx.coroutines.delay71import kotlinx.coroutines.launch72import javax.inject.Inject7374757677787980818283848586878889909192939495@AndroidEntryPoint96class AutofillEntryPickerActivity : FragmentActivity() {9798 @Inject lateinit var vault: VaultRepository99 @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons100 private var debugSecureLease = false101102 @OptIn(ExperimentalMaterial3Api::class)103 override fun onCreate(savedInstanceState: Bundle?) {104 super.onCreate(savedInstanceState)105106 107 108 109 if (!BuildConfig.DEBUG) {110 SecureWindowPolicy.setUserSecure(window, true)111 } else if (!qaSecureCaptureBypass()) {112 113 114 115 SecureWindowPolicy.acquire(window)116 debugSecureLease = true117 }118119 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)120 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)121 122 123 124 125 val rawParsed = pickerParsedFromInputs(126 origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),127 isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),128 callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),129 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),130 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),131 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),132 otpDigitBoxIds = if (Build.VERSION.SDK_INT >= 33) {133 intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS, AutofillId::class.java).orEmpty()134 } else {135 @Suppress("DEPRECATION")136 intent.getParcelableArrayListExtra<AutofillId>(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS).orEmpty()137 },138 cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),139 cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),140 cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),141 cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),142 cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),143 cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),144 )145 val parsed = resolveAutofillOrigin(this, rawParsed)146 val settings = vault.settings.value147 val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT148149 setContent {150 CompositionLocalProvider(LocalFavicons provides favicons) {151 PeachTheme(accent = accent, textSize = settings.textSize) {152 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)153 val scope = rememberCoroutineScope()154 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }155 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }156 var error by remember { mutableStateOf<String?>(null) }157 var busy by remember { mutableStateOf(false) }158159 val allEntries = remember(unlocked) {160 if (unlocked) vault.getEntries() else emptyList()161 }162 val matchedEntries = remember(unlocked, allEntries) {163 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()164 }165166 167 168 val identities = remember(unlocked, allEntries, matchedEntries) {169 if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()170 }171172 173 174 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }175 val pickNow: (VaultEntry) -> Unit = { entry ->176 val epoch = unlockedEpoch177 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()178 else returnDataset(parsed, entry)179 }180181 182 183 184 LaunchedEffect(unlocked) {185 if (!unlocked && canBiometric()) {186 tryBiometric(187 onUnlocked = {188 unlocked = true189 unlockedEpoch = vault.unlockedSessionEpoch()190 },191 onFail = { },192 )193 }194 }195196 ModalBottomSheet(197 onDismissRequest = { cancel() },198 sheetState = sheetState,199 containerColor = peachColors.S1,200 dragHandle = { AccentDragHandle() },201 ) {202 PickerBody(203 origin = parsed.origin,204 typedIdentity = typedUsername ?: typedEmail,205 unlocked = unlocked,206 matchedEntries = matchedEntries,207 allEntries = allEntries,208 rememberOffer = rememberOffer,209 onRemember = { entry, domains ->210 rememberOffer = null211 scope.launch {212 val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry213 runCatching {214 vault.updateEntry(215 current.copy(216 additionalUrls = (current.additionalUrls.orEmpty() +217 domains.map { "https://$it" }).distinct(),218 ),219 )220 }221 pickNow(entry)222 }223 },224 onFillOnce = { entry ->225 rememberOffer = null226 pickNow(entry)227 },228 onRememberDismiss = { rememberOffer = null },229 identities = identities,230 onPickIdentity = { identity ->231 val epoch = unlockedEpoch232 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()233 else finishWithDataset(234 AutofillResponseBuilder.identityDatasetFor(235 this@AutofillEntryPickerActivity, parsed, identity,236 ),237 )238 },239 error = error,240 busy = busy,241 canBiometric = canBiometric(),242 onBiometric = {243 tryBiometric(244 onUnlocked = {245 unlocked = true246 unlockedEpoch = vault.unlockedSessionEpoch()247 },248 onFail = {},249 )250 },251 onUnlock = { password ->252 busy = true253 error = null254 scope.launch {255 when (vault.unlock(password)) {256 is UnlockResult.Success -> {257 unlocked = true258 unlockedEpoch = vault.unlockedSessionEpoch()259 }260 UnlockResult.WrongPassword -> error = "Wrong master password."261 else -> error = "Couldn't unlock."262 }263 busy = false264 }265 },266 onPick = { entry ->267 val offer = rememberOfferDomains(entry, parsed)268 if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer269 },270 onCreateNew = { searchText ->271 launchNewEntry(parsed, typedUsername, typedEmail, searchText)272 },273 )274 }275 }276 }277 }278 }279280 override fun onDestroy() {281 if (debugSecureLease) {282 SecureWindowPolicy.release(window)283 debugSecureLease = false284 }285 super.onDestroy()286 }287288 private fun qaSecureCaptureBypass(): Boolean =289 BuildConfig.DEBUG && (290 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||291 QaFlags.disableSecureCaptureForQa292 )293294 private fun canBiometric(): Boolean =295 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)296297 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {298 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()299 Biometrics.authenticateCrypto(300 activity = this,301 title = "Unlock Peach",302 subtitle = "Fill your saved details",303 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),304 onSuccess = { result ->305 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()306 307 308 309 310 lifecycleScope.launch {311 312 313 314 var key: ByteArray? = null315 try {316 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)317 if (key == null) return@launch onFail()318 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()319 } finally {320 key?.let(com.peachpasswords.vault.VaultCrypto::zero)321 }322 }323 },324 onFail = onFail,325 )326 }327328 private fun returnDataset(329 parsed: ParsedStructure,330 entry: VaultEntry,331 ) {332 val dataset = if (entry.isCard()) {333 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)334 } else {335 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)336 }337 finishWithDataset(dataset)338 }339340 private fun finishWithDataset(dataset: Dataset?) {341 if (dataset == null) { cancel(); return }342 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)343 setResult(RESULT_OK, reply)344 finish()345 }346347 348349350351352353 private fun launchNewEntry(354 parsed: ParsedStructure,355 typedUsername: String?,356 typedEmail: String?,357 searchText: String?,358 ) {359 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin360 val intent = Intent(this, MainActivity::class.java).apply {361 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)362 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)363 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)364 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)365 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)366 }367 startActivity(intent)368 cancel()369 }370371 private fun cancel() {372 setResult(RESULT_CANCELED)373 finish()374 }375376 @Suppress("DEPRECATION")377 private fun intentAutofillId(key: String): AutofillId? =378 if (android.os.Build.VERSION.SDK_INT >= 33) {379 intent.getParcelableExtra(key, AutofillId::class.java)380 } else {381 intent.getParcelableExtra(key)382 }383384 private companion object {385 const val MAX_ROWS = 12386 }387}388389390391392393394internal fun pickerParsedFromInputs(395 origin: String?,396 isWebOrigin: Boolean,397 callerPackage: String?,398 usernameId: AutofillId? = null,399 passwordId: AutofillId? = null,400 otpId: AutofillId? = null,401 otpDigitBoxIds: List<AutofillId> = emptyList(),402 cardNumberId: AutofillId? = null,403 cardExpMonthId: AutofillId? = null,404 cardExpYearId: AutofillId? = null,405 cardExpDateId: AutofillId? = null,406 cardCvcId: AutofillId? = null,407 cardNameId: AutofillId? = null,408): ParsedStructure = ParsedStructure(409 usernameId = usernameId,410 passwordId = passwordId,411 otpId = otpId,412 cardNumberId = cardNumberId,413 cardExpMonthId = cardExpMonthId,414 cardExpYearId = cardExpYearId,415 cardExpDateId = cardExpDateId,416 cardCvcId = cardCvcId,417 cardNameId = cardNameId,418 otpDigitBoxIds = otpDigitBoxIds,419 webDomain = origin.takeIf { isWebOrigin },420 packageName = if (isWebOrigin) callerPackage else origin,421)422423internal fun autofillPickerEntries(424 entries: List<VaultEntry>,425 parsed: ParsedStructure,426 limit: Int,427): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)428429430431432433434internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {435 if (query.isEmpty()) return true436 val needle = query.lowercase()437 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)438 .any { it.lowercase().contains(needle) }439}440441@Composable442private fun AccentDragHandle() {443 val accent = LocalPeachAccent.current444 Row(445 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),446 horizontalArrangement = Arrangement.Center,447 ) {448 Box(449 modifier = Modifier450 .size(width = 32.dp, height = 4.dp)451 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),452 )453 }454}455456@Composable457private fun PickerBody(458 origin: String?,459 typedIdentity: String?,460 unlocked: Boolean,461 matchedEntries: List<VaultEntry>,462 allEntries: List<VaultEntry>,463 rememberOffer: Pair<VaultEntry, List<String>>?,464 onRemember: (VaultEntry, List<String>) -> Unit,465 onFillOnce: (VaultEntry) -> Unit,466 onRememberDismiss: () -> Unit,467 identities: List<SavedIdentity>,468 onPickIdentity: (SavedIdentity) -> Unit,469 error: String?,470 busy: Boolean,471 canBiometric: Boolean,472 onBiometric: () -> Unit,473 onUnlock: (String) -> Unit,474 onPick: (VaultEntry) -> Unit,475 onCreateNew: (String?) -> Unit,476) {477 val accent = LocalPeachAccent.current478 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {479 Text(480 text = "peach",481 color = accent,482 fontFamily = PeachType.Display,483 fontStyle = FontStyle.Italic,484 fontSize = 15.sp,485 )486 Text(487 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",488 color = peachColors.T2,489 fontFamily = PeachType.Mono,490 fontSize = 9.sp,491 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),492 )493494 if (!unlocked) {495 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)496 } else if (rememberOffer != null) {497 RememberSitePrompt(498 entryTitle = rememberOffer!!.first.title,499 domains = rememberOffer!!.second,500 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },501 onFillOnce = { onFillOnce(rememberOffer!!.first) },502 onBack = onRememberDismiss,503 )504 } else {505 506 507 var rawQuery by remember { mutableStateOf("") }508 var debouncedQuery by remember { mutableStateOf("") }509 LaunchedEffect(rawQuery) {510 delay(SEARCH_DEBOUNCE_MS)511 debouncedQuery = rawQuery512 }513 SearchField(514 value = rawQuery,515 onValueChange = { rawQuery = it },516 placeholder = when {517 matchedEntries.isNotEmpty() -> "search for another entry…"518 else -> "search vault…"519 },520 )521522 val showIdentities =523 matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()524 val visibleEntries = when {525 debouncedQuery.isBlank() -> matchedEntries526 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }527 }528 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()529530 val emptyMessage = when {531 matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->532 "No saved entries match this site."533 showSearchEmpty ->534 "No entries match \"${debouncedQuery.trim()}\"."535 else -> null536 }537 if (emptyMessage != null) {538 Text(539 text = emptyMessage,540 color = peachColors.T1,541 fontFamily = PeachType.Body,542 fontSize = 13.sp,543 modifier = Modifier.padding(vertical = 6.dp),544 )545 }546547 if (showIdentities) {548 IdentitySection(identities = identities, onPick = onPickIdentity)549 }550551 if (visibleEntries.isNotEmpty()) {552 LazyColumn {553 items(visibleEntries, key = { it.id }) { entry ->554 PickerRow(entry, onClick = { onPick(entry) })555 }556 }557 }558559 val createOrigin =560 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin561 CreateNewRow(562 origin = createOrigin,563 typedIdentity = typedIdentity,564 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },565 )566 }567 }568}569570@Composable571private fun SearchField(572 value: String,573 onValueChange: (String) -> Unit,574 placeholder: String,575) {576 val accent = LocalPeachAccent.current577 BasicTextField(578 value = value,579 onValueChange = onValueChange,580 singleLine = true,581 textStyle = TextStyle(582 color = peachColors.T0,583 fontFamily = PeachType.Mono,584 fontSize = 14.sp,585 ),586 cursorBrush = SolidColor(accent),587 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),588 keyboardActions = KeyboardActions(onSearch = { }),589 modifier = Modifier590 .fillMaxWidth()591 .background(peachColors.S2, RoundedCornerShape(8.dp))592 .padding(horizontal = 12.dp, vertical = 10.dp),593 decorationBox = { inner ->594 Box(modifier = Modifier.fillMaxWidth()) {595 if (value.isEmpty()) {596 Text(597 text = placeholder,598 color = peachColors.T2,599 fontFamily = PeachType.Mono,600 fontSize = 12.sp,601 )602 }603 inner()604 }605 },606 )607}608609@Composable610private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {611 val accent = LocalPeachAccent.current612 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"613 else "Create new entry"614 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }615 Row(616 modifier = Modifier617 .fillMaxWidth()618 .clickable(onClick = onClick)619 .padding(vertical = 10.dp),620 verticalAlignment = Alignment.CenterVertically,621 ) {622 Box(623 modifier = Modifier624 .size(34.dp)625 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),626 contentAlignment = Alignment.Center,627 ) {628 Text(629 text = "+",630 color = accent,631 fontFamily = PeachType.Display,632 fontStyle = FontStyle.Italic,633 fontSize = 18.sp,634 )635 }636 Column(modifier = Modifier.padding(start = 12.dp)) {637 Text(638 text = title,639 color = accent,640 fontFamily = PeachType.Body,641 fontSize = 14.sp,642 maxLines = 1,643 overflow = TextOverflow.Ellipsis,644 )645 if (subtitle != null) {646 Text(647 text = "prefill: $subtitle",648 color = peachColors.T2,649 fontFamily = PeachType.Mono,650 fontSize = 10.sp,651 maxLines = 1,652 overflow = TextOverflow.Ellipsis,653 )654 }655 }656 }657}658659660661662663664665@Composable666private fun RememberSitePrompt(667 entryTitle: String,668 domains: List<String>,669 onRemember: () -> Unit,670 onFillOnce: () -> Unit,671 onBack: () -> Unit,672) {673 val accent = LocalPeachAccent.current674 Text(675 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",676 color = peachColors.T0,677 fontFamily = PeachType.Body,678 fontSize = 14.sp,679 )680 Text(681 text = "It will be suggested automatically the next time you fill here.",682 color = peachColors.T2,683 fontFamily = PeachType.Mono,684 fontSize = 10.sp,685 modifier = Modifier.padding(top = 4.dp),686 )687 Row(688 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),689 horizontalArrangement = Arrangement.spacedBy(18.dp),690 verticalAlignment = Alignment.CenterVertically,691 ) {692 Text(693 text = "Remember & fill",694 color = accent,695 fontFamily = PeachType.Mono,696 fontSize = 13.sp,697 modifier = Modifier698 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))699 .clickable(onClick = onRemember)700 .padding(horizontal = 12.dp, vertical = 8.dp),701 )702 Text(703 text = "Just fill",704 color = peachColors.T1,705 fontFamily = PeachType.Mono,706 fontSize = 13.sp,707 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),708 )709 Text(710 text = "Back",711 color = peachColors.T2,712 fontFamily = PeachType.Mono,713 fontSize = 13.sp,714 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),715 )716 }717}718719720721722723724@Composable725private fun IdentitySection(726 identities: List<SavedIdentity>,727 onPick: (SavedIdentity) -> Unit,728) {729 val accent = LocalPeachAccent.current730 var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }731 val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }732733 Text(734 text = "use a saved identity",735 color = peachColors.T2,736 fontFamily = PeachType.Mono,737 fontSize = 9.sp,738 modifier = Modifier.padding(bottom = 8.dp),739 )740 Row(741 horizontalArrangement = Arrangement.spacedBy(8.dp),742 modifier = Modifier.padding(bottom = 10.dp),743 ) {744 for (filterMode in IdentityFilterMode.values()) {745 val active = filterMode == mode746 Text(747 text = filterMode.label,748 color = if (active) peachColors.S1 else peachColors.T2,749 fontFamily = PeachType.Mono,750 fontSize = 10.sp,751 modifier = Modifier752 .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))753 .clickable { mode = filterMode }754 .padding(horizontal = 10.dp, vertical = 5.dp),755 )756 }757 }758 LazyColumn {759 items(visible, key = { it.type.name + ":" + it.value }) { identity ->760 IdentityRow(identity, onClick = { onPick(identity) })761 }762 }763}764765@Composable766private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {767 Column(768 modifier = Modifier769 .fillMaxWidth()770 .clickable(onClick = onClick)771 .padding(vertical = 8.dp),772 ) {773 Text(774 text = identity.value,775 color = peachColors.T0,776 fontFamily = PeachType.Body,777 fontSize = 13.sp,778 maxLines = 1,779 overflow = TextOverflow.Ellipsis,780 )781 val meta = listOfNotNull(782 identity.exampleDomain,783 if (identity.type == IdentityType.EMAIL) "email" else "username",784 ).joinToString(" \u00b7 ")785 Text(786 text = meta,787 color = peachColors.T2,788 fontFamily = PeachType.Mono,789 fontSize = 9.sp,790 maxLines = 1,791 overflow = TextOverflow.Ellipsis,792 )793 }794}795796@Composable797private fun UnlockBody(798 error: String?,799 busy: Boolean,800 canBiometric: Boolean,801 onBiometric: () -> Unit,802 onUnlock: (String) -> Unit,803) {804 val accent = LocalPeachAccent.current805 var password by remember { mutableStateOf("") }806807 Text(808 text = "Unlock to fill",809 color = peachColors.T0,810 fontFamily = PeachType.Body,811 fontSize = 15.sp,812 modifier = Modifier.padding(bottom = 10.dp),813 )814 BasicTextField(815 value = password,816 onValueChange = { password = it },817 singleLine = true,818 enabled = !busy,819 textStyle = TextStyle(820 color = peachColors.T0,821 fontFamily = PeachType.Mono,822 fontSize = 16.sp,823 ),824 cursorBrush = SolidColor(accent),825 visualTransformation = PasswordVisualTransformation(),826 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),827 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),828 modifier = Modifier829 .fillMaxWidth()830 .background(peachColors.S2, RoundedCornerShape(8.dp))831 .padding(horizontal = 12.dp, vertical = 12.dp),832 )833 if (error != null) {834 Text(835 text = error,836 color = peachColors.Breach,837 fontFamily = PeachType.Body,838 fontSize = 12.sp,839 modifier = Modifier.padding(top = 8.dp),840 )841 }842 Row(843 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),844 horizontalArrangement = Arrangement.spacedBy(20.dp),845 verticalAlignment = Alignment.CenterVertically,846 ) {847 if (canBiometric) {848 Text(849 text = "Use biometrics",850 color = accent,851 fontFamily = PeachType.Mono,852 fontSize = 13.sp,853 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),854 )855 }856 Text(857 text = if (busy) "Unlocking…" else "Unlock",858 color = if (password.isEmpty() || busy) peachColors.T2 else accent,859 fontFamily = PeachType.Mono,860 fontSize = 13.sp,861 modifier = Modifier862 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }863 .padding(vertical = 8.dp),864 )865 }866}867868@Composable869private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {870 Row(871 modifier = Modifier872 .fillMaxWidth()873 .clickable(onClick = onClick)874 .padding(vertical = 9.dp),875 verticalAlignment = Alignment.CenterVertically,876 ) {877 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)878 Column(modifier = Modifier.padding(start = 12.dp)) {879 Text(880 text = entry.title,881 color = peachColors.T0,882 fontFamily = PeachType.Body,883 fontSize = 15.sp,884 maxLines = 1,885 overflow = TextOverflow.Ellipsis,886 )887 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""888 if (sub.isNotBlank()) {889 Text(890 text = sub,891 color = peachColors.T2,892 fontFamily = PeachType.Mono,893 fontSize = 11.sp,894 maxLines = 1,895 overflow = TextOverflow.Ellipsis,896 )897 }898 }899 }900}901902903private const val SEARCH_DEBOUNCE_MS = 200L