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 dataset = if (entry.isCard()) {337 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)338 } else {339 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)340 }341 finishWithDataset(dataset)342 }343344 private fun finishWithDataset(dataset: Dataset?) {345 if (dataset == null) { cancel(); return }346 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)347 setResult(RESULT_OK, reply)348 finish()349 }350351 352353354355356357 private fun launchNewEntry(358 parsed: ParsedStructure,359 typedUsername: String?,360 typedEmail: String?,361 searchText: String?,362 ) {363 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin364 val intent = Intent(this, MainActivity::class.java).apply {365 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)366 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN, AutofillIntents.issueNewEntryToken())367 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)368 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)369 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)370 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)371 }372 startActivity(intent)373 cancel()374 }375376 private fun cancel() {377 setResult(RESULT_CANCELED)378 finish()379 }380381 @Suppress("DEPRECATION")382 private fun intentAutofillId(key: String): AutofillId? =383 if (android.os.Build.VERSION.SDK_INT >= 33) {384 intent.getParcelableExtra(key, AutofillId::class.java)385 } else {386 intent.getParcelableExtra(key)387 }388389 private companion object {390 const val MAX_ROWS = 12391 }392}393394395396397398399internal fun pickerParsedFromInputs(400 origin: String?,401 isWebOrigin: Boolean,402 callerPackage: String?,403 usernameId: AutofillId? = null,404 passwordId: AutofillId? = null,405 otpId: AutofillId? = null,406 otpDigitBoxIds: List<AutofillId> = emptyList(),407 cardNumberId: AutofillId? = null,408 cardExpMonthId: AutofillId? = null,409 cardExpYearId: AutofillId? = null,410 cardExpDateId: AutofillId? = null,411 cardCvcId: AutofillId? = null,412 cardNameId: AutofillId? = null,413): ParsedStructure = ParsedStructure(414 usernameId = usernameId,415 passwordId = passwordId,416 otpId = otpId,417 cardNumberId = cardNumberId,418 cardExpMonthId = cardExpMonthId,419 cardExpYearId = cardExpYearId,420 cardExpDateId = cardExpDateId,421 cardCvcId = cardCvcId,422 cardNameId = cardNameId,423 otpDigitBoxIds = otpDigitBoxIds,424 webDomain = origin.takeIf { isWebOrigin },425 packageName = if (isWebOrigin) callerPackage else origin,426)427428internal fun autofillPickerEntries(429 entries: List<VaultEntry>,430 parsed: ParsedStructure,431 limit: Int,432): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)433434435436437438439internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {440 if (query.isEmpty()) return true441 val needle = query.lowercase()442 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)443 .any { it.lowercase().contains(needle) }444}445446@Composable447private fun AccentDragHandle() {448 val accent = LocalPeachAccent.current449 Row(450 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),451 horizontalArrangement = Arrangement.Center,452 ) {453 Box(454 modifier = Modifier455 .size(width = 32.dp, height = 4.dp)456 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),457 )458 }459}460461@Composable462private fun PickerBody(463 origin: String?,464 typedIdentity: String?,465 unlocked: Boolean,466 matchedEntries: List<VaultEntry>,467 allEntries: List<VaultEntry>,468 rememberOffer: Pair<VaultEntry, List<String>>?,469 onRemember: (VaultEntry, List<String>) -> Unit,470 onFillOnce: (VaultEntry) -> Unit,471 onRememberDismiss: () -> Unit,472 identities: List<SavedIdentity>,473 onPickIdentity: (SavedIdentity) -> Unit,474 error: String?,475 busy: Boolean,476 canBiometric: Boolean,477 onBiometric: () -> Unit,478 onUnlock: (String) -> Unit,479 onPick: (VaultEntry) -> Unit,480 onCreateNew: (String?) -> Unit,481) {482 val accent = LocalPeachAccent.current483 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {484 Text(485 text = "peach",486 color = accent,487 fontFamily = PeachType.Display,488 fontStyle = FontStyle.Italic,489 fontSize = 15.sp,490 )491 Text(492 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",493 color = peachColors.T2,494 fontFamily = PeachType.Mono,495 fontSize = 9.sp,496 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),497 )498499 if (!unlocked) {500 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)501 } else if (rememberOffer != null) {502 RememberSitePrompt(503 entryTitle = rememberOffer!!.first.title,504 domains = rememberOffer!!.second,505 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },506 onFillOnce = { onFillOnce(rememberOffer!!.first) },507 onBack = onRememberDismiss,508 )509 } else {510 511 512 var rawQuery by remember { mutableStateOf("") }513 var debouncedQuery by remember { mutableStateOf("") }514 LaunchedEffect(rawQuery) {515 delay(SEARCH_DEBOUNCE_MS)516 debouncedQuery = rawQuery517 }518 SearchField(519 value = rawQuery,520 onValueChange = { rawQuery = it },521 placeholder = when {522 matchedEntries.isNotEmpty() -> "search for another entry…"523 else -> "search vault…"524 },525 )526527 val showIdentities =528 matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()529 val visibleEntries = when {530 debouncedQuery.isBlank() -> matchedEntries531 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }532 }533 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()534535 val emptyMessage = when {536 matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->537 "No saved entries match this site."538 showSearchEmpty ->539 "No entries match \"${debouncedQuery.trim()}\"."540 else -> null541 }542 if (emptyMessage != null) {543 Text(544 text = emptyMessage,545 color = peachColors.T1,546 fontFamily = PeachType.Body,547 fontSize = 13.sp,548 modifier = Modifier.padding(vertical = 6.dp),549 )550 }551552 if (showIdentities) {553 IdentitySection(identities = identities, onPick = onPickIdentity)554 }555556 if (visibleEntries.isNotEmpty()) {557 LazyColumn {558 items(visibleEntries, key = { it.id }) { entry ->559 PickerRow(entry, onClick = { onPick(entry) })560 }561 }562 }563564 val createOrigin =565 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin566 CreateNewRow(567 origin = createOrigin,568 typedIdentity = typedIdentity,569 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },570 )571 }572 }573}574575@Composable576private fun SearchField(577 value: String,578 onValueChange: (String) -> Unit,579 placeholder: String,580) {581 val accent = LocalPeachAccent.current582 BasicTextField(583 value = value,584 onValueChange = onValueChange,585 singleLine = true,586 textStyle = TextStyle(587 color = peachColors.T0,588 fontFamily = PeachType.Mono,589 fontSize = 14.sp,590 ),591 cursorBrush = SolidColor(accent),592 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),593 keyboardActions = KeyboardActions(onSearch = { }),594 modifier = Modifier595 .fillMaxWidth()596 .background(peachColors.S2, RoundedCornerShape(8.dp))597 .padding(horizontal = 12.dp, vertical = 10.dp),598 decorationBox = { inner ->599 Box(modifier = Modifier.fillMaxWidth()) {600 if (value.isEmpty()) {601 Text(602 text = placeholder,603 color = peachColors.T2,604 fontFamily = PeachType.Mono,605 fontSize = 12.sp,606 )607 }608 inner()609 }610 },611 )612}613614@Composable615private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {616 val accent = LocalPeachAccent.current617 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"618 else "Create new entry"619 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }620 Row(621 modifier = Modifier622 .fillMaxWidth()623 .clickable(onClick = onClick)624 .padding(vertical = 10.dp),625 verticalAlignment = Alignment.CenterVertically,626 ) {627 Box(628 modifier = Modifier629 .size(34.dp)630 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),631 contentAlignment = Alignment.Center,632 ) {633 Text(634 text = "+",635 color = accent,636 fontFamily = PeachType.Display,637 fontStyle = FontStyle.Italic,638 fontSize = 18.sp,639 )640 }641 Column(modifier = Modifier.padding(start = 12.dp)) {642 Text(643 text = title,644 color = accent,645 fontFamily = PeachType.Body,646 fontSize = 14.sp,647 maxLines = 1,648 overflow = TextOverflow.Ellipsis,649 )650 if (subtitle != null) {651 Text(652 text = "prefill: $subtitle",653 color = peachColors.T2,654 fontFamily = PeachType.Mono,655 fontSize = 10.sp,656 maxLines = 1,657 overflow = TextOverflow.Ellipsis,658 )659 }660 }661 }662}663664665666667668669670@Composable671private fun RememberSitePrompt(672 entryTitle: String,673 domains: List<String>,674 onRemember: () -> Unit,675 onFillOnce: () -> Unit,676 onBack: () -> Unit,677) {678 val accent = LocalPeachAccent.current679 Text(680 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",681 color = peachColors.T0,682 fontFamily = PeachType.Body,683 fontSize = 14.sp,684 )685 Text(686 text = "It will be suggested automatically the next time you fill here.",687 color = peachColors.T2,688 fontFamily = PeachType.Mono,689 fontSize = 10.sp,690 modifier = Modifier.padding(top = 4.dp),691 )692 Row(693 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),694 horizontalArrangement = Arrangement.spacedBy(18.dp),695 verticalAlignment = Alignment.CenterVertically,696 ) {697 Text(698 text = "Remember & fill",699 color = accent,700 fontFamily = PeachType.Mono,701 fontSize = 13.sp,702 modifier = Modifier703 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))704 .clickable(onClick = onRemember)705 .padding(horizontal = 12.dp, vertical = 8.dp),706 )707 Text(708 text = "Just fill",709 color = peachColors.T1,710 fontFamily = PeachType.Mono,711 fontSize = 13.sp,712 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),713 )714 Text(715 text = "Back",716 color = peachColors.T2,717 fontFamily = PeachType.Mono,718 fontSize = 13.sp,719 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),720 )721 }722}723724725726727728729@Composable730private fun IdentitySection(731 identities: List<SavedIdentity>,732 onPick: (SavedIdentity) -> Unit,733) {734 val accent = LocalPeachAccent.current735 var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }736 val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }737738 Text(739 text = "use a saved identity",740 color = peachColors.T2,741 fontFamily = PeachType.Mono,742 fontSize = 9.sp,743 modifier = Modifier.padding(bottom = 8.dp),744 )745 Row(746 horizontalArrangement = Arrangement.spacedBy(8.dp),747 modifier = Modifier.padding(bottom = 10.dp),748 ) {749 for (filterMode in IdentityFilterMode.values()) {750 val active = filterMode == mode751 Text(752 text = filterMode.label,753 color = if (active) peachColors.S1 else peachColors.T2,754 fontFamily = PeachType.Mono,755 fontSize = 10.sp,756 modifier = Modifier757 .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))758 .clickable { mode = filterMode }759 .padding(horizontal = 10.dp, vertical = 5.dp),760 )761 }762 }763 LazyColumn {764 items(visible, key = { it.type.name + ":" + it.value }) { identity ->765 IdentityRow(identity, onClick = { onPick(identity) })766 }767 }768}769770@Composable771private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {772 Column(773 modifier = Modifier774 .fillMaxWidth()775 .clickable(onClick = onClick)776 .padding(vertical = 8.dp),777 ) {778 Text(779 text = identity.value,780 color = peachColors.T0,781 fontFamily = PeachType.Body,782 fontSize = 13.sp,783 maxLines = 1,784 overflow = TextOverflow.Ellipsis,785 )786 val meta = listOfNotNull(787 identity.exampleDomain,788 if (identity.type == IdentityType.EMAIL) "email" else "username",789 ).joinToString(" \u00b7 ")790 Text(791 text = meta,792 color = peachColors.T2,793 fontFamily = PeachType.Mono,794 fontSize = 9.sp,795 maxLines = 1,796 overflow = TextOverflow.Ellipsis,797 )798 }799}800801@Composable802private fun UnlockBody(803 error: String?,804 busy: Boolean,805 canBiometric: Boolean,806 onBiometric: () -> Unit,807 onUnlock: (String) -> Unit,808) {809 val accent = LocalPeachAccent.current810 var password by remember { mutableStateOf("") }811812 Text(813 text = "Unlock to fill",814 color = peachColors.T0,815 fontFamily = PeachType.Body,816 fontSize = 15.sp,817 modifier = Modifier.padding(bottom = 10.dp),818 )819 BasicTextField(820 value = password,821 onValueChange = { password = it },822 singleLine = true,823 enabled = !busy,824 textStyle = TextStyle(825 color = peachColors.T0,826 fontFamily = PeachType.Mono,827 fontSize = 16.sp,828 ),829 cursorBrush = SolidColor(accent),830 visualTransformation = PasswordVisualTransformation(),831 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),832 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),833 modifier = Modifier834 .fillMaxWidth()835 .background(peachColors.S2, RoundedCornerShape(8.dp))836 .padding(horizontal = 12.dp, vertical = 12.dp),837 )838 if (error != null) {839 Text(840 text = error,841 color = peachColors.Breach,842 fontFamily = PeachType.Body,843 fontSize = 12.sp,844 modifier = Modifier.padding(top = 8.dp),845 )846 }847 Row(848 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),849 horizontalArrangement = Arrangement.spacedBy(20.dp),850 verticalAlignment = Alignment.CenterVertically,851 ) {852 if (canBiometric) {853 Text(854 text = "Use biometrics",855 color = accent,856 fontFamily = PeachType.Mono,857 fontSize = 13.sp,858 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),859 )860 }861 Text(862 text = if (busy) "Unlocking…" else "Unlock",863 color = if (password.isEmpty() || busy) peachColors.T2 else accent,864 fontFamily = PeachType.Mono,865 fontSize = 13.sp,866 modifier = Modifier867 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }868 .padding(vertical = 8.dp),869 )870 }871}872873@Composable874private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {875 Row(876 modifier = Modifier877 .fillMaxWidth()878 .clickable(onClick = onClick)879 .padding(vertical = 9.dp),880 verticalAlignment = Alignment.CenterVertically,881 ) {882 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)883 Column(modifier = Modifier.padding(start = 12.dp)) {884 Text(885 text = entry.title,886 color = peachColors.T0,887 fontFamily = PeachType.Body,888 fontSize = 15.sp,889 maxLines = 1,890 overflow = TextOverflow.Ellipsis,891 )892 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""893 if (sub.isNotBlank()) {894 Text(895 text = sub,896 color = peachColors.T2,897 fontFamily = PeachType.Mono,898 fontSize = 11.sp,899 maxLines = 1,900 overflow = TextOverflow.Ellipsis,901 )902 }903 }904 }905}906907908private const val SEARCH_DEBOUNCE_MS = 200L