1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Bundle5import android.service.autofill.Dataset6import 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.components.QaFlags55import com.peachpasswords.ui.components.SecureWindowPolicy56import com.peachpasswords.ui.theme.ACCENT_COLORS57import com.peachpasswords.ui.theme.DEFAULT_ACCENT58import com.peachpasswords.ui.theme.LocalPeachAccent59import com.peachpasswords.ui.theme.peachColors60import com.peachpasswords.ui.theme.PeachTheme61import com.peachpasswords.ui.theme.PeachType62import com.peachpasswords.vault.UnlockResult63import com.peachpasswords.vault.VaultEntry64import com.peachpasswords.vault.isCard65import com.peachpasswords.vault.VaultRepository66import dagger.hilt.android.AndroidEntryPoint67import kotlinx.coroutines.delay68import kotlinx.coroutines.launch69import javax.inject.Inject7071727374757677787980818283848586878889909192@AndroidEntryPoint93class AutofillEntryPickerActivity : FragmentActivity() {9495 @Inject lateinit var vault: VaultRepository96 private var debugSecureLease = false9798 @OptIn(ExperimentalMaterial3Api::class)99 override fun onCreate(savedInstanceState: Bundle?) {100 super.onCreate(savedInstanceState)101102 103 104 105 if (!BuildConfig.DEBUG) {106 SecureWindowPolicy.setUserSecure(window, true)107 } else if (!qaSecureCaptureBypass()) {108 109 110 111 SecureWindowPolicy.acquire(window)112 debugSecureLease = true113 }114115 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)116 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)117 118 119 120 121 val rawParsed = pickerParsedFromInputs(122 origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),123 isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),124 callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),125 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),126 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),127 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),128 cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),129 cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),130 cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),131 cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),132 cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),133 cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),134 )135 val parsed = resolveAutofillOrigin(this, rawParsed)136 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT137138 setContent {139 PeachTheme(accent = accent) {140 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)141 val scope = rememberCoroutineScope()142 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }143 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }144 var error by remember { mutableStateOf<String?>(null) }145 var busy by remember { mutableStateOf(false) }146147 val allEntries = remember(unlocked) {148 if (unlocked) vault.getEntries() else emptyList()149 }150 val matchedEntries = remember(unlocked, allEntries) {151 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()152 }153154 155 156 val identities = remember(unlocked, allEntries, matchedEntries) {157 if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()158 }159160 161 162 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }163 val pickNow: (VaultEntry) -> Unit = { entry ->164 val epoch = unlockedEpoch165 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()166 else returnDataset(parsed, entry)167 }168169 170 171 172 LaunchedEffect(unlocked) {173 if (!unlocked && canBiometric()) {174 tryBiometric(175 onUnlocked = {176 unlocked = true177 unlockedEpoch = vault.unlockedSessionEpoch()178 },179 onFail = { },180 )181 }182 }183184 ModalBottomSheet(185 onDismissRequest = { cancel() },186 sheetState = sheetState,187 containerColor = peachColors.S1,188 dragHandle = { AccentDragHandle() },189 ) {190 PickerBody(191 origin = parsed.origin,192 typedIdentity = typedUsername ?: typedEmail,193 unlocked = unlocked,194 matchedEntries = matchedEntries,195 allEntries = allEntries,196 rememberOffer = rememberOffer,197 onRemember = { entry, domains ->198 rememberOffer = null199 scope.launch {200 val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry201 runCatching {202 vault.updateEntry(203 current.copy(204 additionalUrls = (current.additionalUrls.orEmpty() +205 domains.map { "https://$it" }).distinct(),206 ),207 )208 }209 pickNow(entry)210 }211 },212 onFillOnce = { entry ->213 rememberOffer = null214 pickNow(entry)215 },216 onRememberDismiss = { rememberOffer = null },217 identities = identities,218 onPickIdentity = { identity ->219 val epoch = unlockedEpoch220 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()221 else finishWithDataset(222 AutofillResponseBuilder.identityDatasetFor(223 this@AutofillEntryPickerActivity, parsed, identity,224 ),225 )226 },227 error = error,228 busy = busy,229 canBiometric = canBiometric(),230 onBiometric = {231 tryBiometric(232 onUnlocked = {233 unlocked = true234 unlockedEpoch = vault.unlockedSessionEpoch()235 },236 onFail = {},237 )238 },239 onUnlock = { password ->240 busy = true241 error = null242 scope.launch {243 when (vault.unlock(password)) {244 is UnlockResult.Success -> {245 unlocked = true246 unlockedEpoch = vault.unlockedSessionEpoch()247 }248 UnlockResult.WrongPassword -> error = "Wrong master password."249 else -> error = "Couldn't unlock."250 }251 busy = false252 }253 },254 onPick = { entry ->255 val offer = rememberOfferDomains(entry, parsed)256 if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer257 },258 onCreateNew = { searchText ->259 launchNewEntry(parsed, typedUsername, typedEmail, searchText)260 },261 )262 }263 }264 }265 }266267 override fun onDestroy() {268 if (debugSecureLease) {269 SecureWindowPolicy.release(window)270 debugSecureLease = false271 }272 super.onDestroy()273 }274275 private fun qaSecureCaptureBypass(): Boolean =276 BuildConfig.DEBUG && (277 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||278 QaFlags.disableSecureCaptureForQa279 )280281 private fun canBiometric(): Boolean =282 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)283284 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {285 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()286 Biometrics.authenticateCrypto(287 activity = this,288 title = "Unlock Peach",289 subtitle = "Fill your saved details",290 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),291 onSuccess = { result ->292 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()293 294 295 296 297 lifecycleScope.launch {298 299 300 301 var key: ByteArray? = null302 try {303 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)304 if (key == null) return@launch onFail()305 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()306 } finally {307 key?.let(com.peachpasswords.vault.VaultCrypto::zero)308 }309 }310 },311 onFail = onFail,312 )313 }314315 private fun returnDataset(316 parsed: ParsedStructure,317 entry: VaultEntry,318 ) {319 val dataset = if (entry.isCard()) {320 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)321 } else {322 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)323 }324 finishWithDataset(dataset)325 }326327 private fun finishWithDataset(dataset: Dataset?) {328 if (dataset == null) { cancel(); return }329 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)330 setResult(RESULT_OK, reply)331 finish()332 }333334 335336337338339340 private fun launchNewEntry(341 parsed: ParsedStructure,342 typedUsername: String?,343 typedEmail: String?,344 searchText: String?,345 ) {346 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin347 val intent = Intent(this, MainActivity::class.java).apply {348 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)349 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)350 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)351 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)352 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)353 }354 startActivity(intent)355 cancel()356 }357358 private fun cancel() {359 setResult(RESULT_CANCELED)360 finish()361 }362363 @Suppress("DEPRECATION")364 private fun intentAutofillId(key: String): AutofillId? =365 if (android.os.Build.VERSION.SDK_INT >= 33) {366 intent.getParcelableExtra(key, AutofillId::class.java)367 } else {368 intent.getParcelableExtra(key)369 }370371 private companion object {372 const val MAX_ROWS = 12373 }374}375376377378379380381internal fun pickerParsedFromInputs(382 origin: String?,383 isWebOrigin: Boolean,384 callerPackage: String?,385 usernameId: AutofillId? = null,386 passwordId: AutofillId? = null,387 otpId: AutofillId? = null,388 cardNumberId: AutofillId? = null,389 cardExpMonthId: AutofillId? = null,390 cardExpYearId: AutofillId? = null,391 cardExpDateId: AutofillId? = null,392 cardCvcId: AutofillId? = null,393 cardNameId: AutofillId? = null,394): ParsedStructure = ParsedStructure(395 usernameId = usernameId,396 passwordId = passwordId,397 otpId = otpId,398 cardNumberId = cardNumberId,399 cardExpMonthId = cardExpMonthId,400 cardExpYearId = cardExpYearId,401 cardExpDateId = cardExpDateId,402 cardCvcId = cardCvcId,403 cardNameId = cardNameId,404 webDomain = origin.takeIf { isWebOrigin },405 packageName = if (isWebOrigin) callerPackage else origin,406)407408internal fun autofillPickerEntries(409 entries: List<VaultEntry>,410 parsed: ParsedStructure,411 limit: Int,412): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)413414415416417418419internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {420 if (query.isEmpty()) return true421 val needle = query.lowercase()422 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)423 .any { it.lowercase().contains(needle) }424}425426@Composable427private fun AccentDragHandle() {428 val accent = LocalPeachAccent.current429 Row(430 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),431 horizontalArrangement = Arrangement.Center,432 ) {433 Box(434 modifier = Modifier435 .size(width = 32.dp, height = 4.dp)436 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),437 )438 }439}440441@Composable442private fun PickerBody(443 origin: String?,444 typedIdentity: String?,445 unlocked: Boolean,446 matchedEntries: List<VaultEntry>,447 allEntries: List<VaultEntry>,448 rememberOffer: Pair<VaultEntry, List<String>>?,449 onRemember: (VaultEntry, List<String>) -> Unit,450 onFillOnce: (VaultEntry) -> Unit,451 onRememberDismiss: () -> Unit,452 identities: List<SavedIdentity>,453 onPickIdentity: (SavedIdentity) -> Unit,454 error: String?,455 busy: Boolean,456 canBiometric: Boolean,457 onBiometric: () -> Unit,458 onUnlock: (String) -> Unit,459 onPick: (VaultEntry) -> Unit,460 onCreateNew: (String?) -> Unit,461) {462 val accent = LocalPeachAccent.current463 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {464 Text(465 text = "peach",466 color = accent,467 fontFamily = PeachType.Display,468 fontStyle = FontStyle.Italic,469 fontSize = 15.sp,470 )471 Text(472 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",473 color = peachColors.T2,474 fontFamily = PeachType.Mono,475 fontSize = 9.sp,476 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),477 )478479 if (!unlocked) {480 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)481 } else if (rememberOffer != null) {482 RememberSitePrompt(483 entryTitle = rememberOffer!!.first.title,484 domains = rememberOffer!!.second,485 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },486 onFillOnce = { onFillOnce(rememberOffer!!.first) },487 onBack = onRememberDismiss,488 )489 } else {490 491 492 var rawQuery by remember { mutableStateOf("") }493 var debouncedQuery by remember { mutableStateOf("") }494 LaunchedEffect(rawQuery) {495 delay(SEARCH_DEBOUNCE_MS)496 debouncedQuery = rawQuery497 }498 SearchField(499 value = rawQuery,500 onValueChange = { rawQuery = it },501 placeholder = when {502 matchedEntries.isNotEmpty() -> "search for another entry…"503 else -> "search vault…"504 },505 )506507 val showIdentities =508 matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()509 val visibleEntries = when {510 debouncedQuery.isBlank() -> matchedEntries511 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }512 }513 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()514515 val emptyMessage = when {516 matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->517 "No saved entries match this site."518 showSearchEmpty ->519 "No entries match \"${debouncedQuery.trim()}\"."520 else -> null521 }522 if (emptyMessage != null) {523 Text(524 text = emptyMessage,525 color = peachColors.T1,526 fontFamily = PeachType.Body,527 fontSize = 13.sp,528 modifier = Modifier.padding(vertical = 6.dp),529 )530 }531532 if (showIdentities) {533 IdentitySection(identities = identities, onPick = onPickIdentity)534 }535536 if (visibleEntries.isNotEmpty()) {537 LazyColumn {538 items(visibleEntries, key = { it.id }) { entry ->539 PickerRow(entry, onClick = { onPick(entry) })540 }541 }542 }543544 val createOrigin =545 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin546 CreateNewRow(547 origin = createOrigin,548 typedIdentity = typedIdentity,549 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },550 )551 }552 }553}554555@Composable556private fun SearchField(557 value: String,558 onValueChange: (String) -> Unit,559 placeholder: String,560) {561 val accent = LocalPeachAccent.current562 BasicTextField(563 value = value,564 onValueChange = onValueChange,565 singleLine = true,566 textStyle = TextStyle(567 color = peachColors.T0,568 fontFamily = PeachType.Mono,569 fontSize = 14.sp,570 ),571 cursorBrush = SolidColor(accent),572 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),573 keyboardActions = KeyboardActions(onSearch = { }),574 modifier = Modifier575 .fillMaxWidth()576 .background(peachColors.S2, RoundedCornerShape(8.dp))577 .padding(horizontal = 12.dp, vertical = 10.dp),578 decorationBox = { inner ->579 Box(modifier = Modifier.fillMaxWidth()) {580 if (value.isEmpty()) {581 Text(582 text = placeholder,583 color = peachColors.T2,584 fontFamily = PeachType.Mono,585 fontSize = 12.sp,586 )587 }588 inner()589 }590 },591 )592}593594@Composable595private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {596 val accent = LocalPeachAccent.current597 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"598 else "Create new entry"599 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }600 Row(601 modifier = Modifier602 .fillMaxWidth()603 .clickable(onClick = onClick)604 .padding(vertical = 10.dp),605 verticalAlignment = Alignment.CenterVertically,606 ) {607 Box(608 modifier = Modifier609 .size(34.dp)610 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),611 contentAlignment = Alignment.Center,612 ) {613 Text(614 text = "+",615 color = accent,616 fontFamily = PeachType.Display,617 fontStyle = FontStyle.Italic,618 fontSize = 18.sp,619 )620 }621 Column(modifier = Modifier.padding(start = 12.dp)) {622 Text(623 text = title,624 color = accent,625 fontFamily = PeachType.Body,626 fontSize = 14.sp,627 maxLines = 1,628 overflow = TextOverflow.Ellipsis,629 )630 if (subtitle != null) {631 Text(632 text = "prefill: $subtitle",633 color = peachColors.T2,634 fontFamily = PeachType.Mono,635 fontSize = 10.sp,636 maxLines = 1,637 overflow = TextOverflow.Ellipsis,638 )639 }640 }641 }642}643644645646647648649650@Composable651private fun RememberSitePrompt(652 entryTitle: String,653 domains: List<String>,654 onRemember: () -> Unit,655 onFillOnce: () -> Unit,656 onBack: () -> Unit,657) {658 val accent = LocalPeachAccent.current659 Text(660 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",661 color = peachColors.T0,662 fontFamily = PeachType.Body,663 fontSize = 14.sp,664 )665 Text(666 text = "It will be suggested automatically the next time you fill here.",667 color = peachColors.T2,668 fontFamily = PeachType.Mono,669 fontSize = 10.sp,670 modifier = Modifier.padding(top = 4.dp),671 )672 Row(673 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),674 horizontalArrangement = Arrangement.spacedBy(18.dp),675 verticalAlignment = Alignment.CenterVertically,676 ) {677 Text(678 text = "Remember & fill",679 color = accent,680 fontFamily = PeachType.Mono,681 fontSize = 13.sp,682 modifier = Modifier683 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))684 .clickable(onClick = onRemember)685 .padding(horizontal = 12.dp, vertical = 8.dp),686 )687 Text(688 text = "Just fill",689 color = peachColors.T1,690 fontFamily = PeachType.Mono,691 fontSize = 13.sp,692 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),693 )694 Text(695 text = "Back",696 color = peachColors.T2,697 fontFamily = PeachType.Mono,698 fontSize = 13.sp,699 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),700 )701 }702}703704705706707708709@Composable710private fun IdentitySection(711 identities: List<SavedIdentity>,712 onPick: (SavedIdentity) -> Unit,713) {714 val accent = LocalPeachAccent.current715 var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }716 val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }717718 Text(719 text = "use a saved identity",720 color = peachColors.T2,721 fontFamily = PeachType.Mono,722 fontSize = 9.sp,723 modifier = Modifier.padding(bottom = 8.dp),724 )725 Row(726 horizontalArrangement = Arrangement.spacedBy(8.dp),727 modifier = Modifier.padding(bottom = 10.dp),728 ) {729 for (filterMode in IdentityFilterMode.values()) {730 val active = filterMode == mode731 Text(732 text = filterMode.label,733 color = if (active) peachColors.S1 else peachColors.T2,734 fontFamily = PeachType.Mono,735 fontSize = 10.sp,736 modifier = Modifier737 .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))738 .clickable { mode = filterMode }739 .padding(horizontal = 10.dp, vertical = 5.dp),740 )741 }742 }743 LazyColumn {744 items(visible, key = { it.type.name + ":" + it.value }) { identity ->745 IdentityRow(identity, onClick = { onPick(identity) })746 }747 }748}749750@Composable751private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {752 Column(753 modifier = Modifier754 .fillMaxWidth()755 .clickable(onClick = onClick)756 .padding(vertical = 8.dp),757 ) {758 Text(759 text = identity.value,760 color = peachColors.T0,761 fontFamily = PeachType.Body,762 fontSize = 13.sp,763 maxLines = 1,764 overflow = TextOverflow.Ellipsis,765 )766 val meta = listOfNotNull(767 identity.exampleDomain,768 if (identity.type == IdentityType.EMAIL) "email" else "username",769 ).joinToString(" \u00b7 ")770 Text(771 text = meta,772 color = peachColors.T2,773 fontFamily = PeachType.Mono,774 fontSize = 9.sp,775 maxLines = 1,776 overflow = TextOverflow.Ellipsis,777 )778 }779}780781@Composable782private fun UnlockBody(783 error: String?,784 busy: Boolean,785 canBiometric: Boolean,786 onBiometric: () -> Unit,787 onUnlock: (String) -> Unit,788) {789 val accent = LocalPeachAccent.current790 var password by remember { mutableStateOf("") }791792 Text(793 text = "Unlock to fill",794 color = peachColors.T0,795 fontFamily = PeachType.Body,796 fontSize = 15.sp,797 modifier = Modifier.padding(bottom = 10.dp),798 )799 BasicTextField(800 value = password,801 onValueChange = { password = it },802 singleLine = true,803 enabled = !busy,804 textStyle = TextStyle(805 color = peachColors.T0,806 fontFamily = PeachType.Mono,807 fontSize = 16.sp,808 ),809 cursorBrush = SolidColor(accent),810 visualTransformation = PasswordVisualTransformation(),811 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),812 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),813 modifier = Modifier814 .fillMaxWidth()815 .background(peachColors.S2, RoundedCornerShape(8.dp))816 .padding(horizontal = 12.dp, vertical = 12.dp),817 )818 if (error != null) {819 Text(820 text = error,821 color = peachColors.Breach,822 fontFamily = PeachType.Body,823 fontSize = 12.sp,824 modifier = Modifier.padding(top = 8.dp),825 )826 }827 Row(828 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),829 horizontalArrangement = Arrangement.spacedBy(20.dp),830 verticalAlignment = Alignment.CenterVertically,831 ) {832 if (canBiometric) {833 Text(834 text = "Use biometrics",835 color = accent,836 fontFamily = PeachType.Mono,837 fontSize = 13.sp,838 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),839 )840 }841 Text(842 text = if (busy) "Unlocking…" else "Unlock",843 color = if (password.isEmpty() || busy) peachColors.T2 else accent,844 fontFamily = PeachType.Mono,845 fontSize = 13.sp,846 modifier = Modifier847 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }848 .padding(vertical = 8.dp),849 )850 }851}852853@Composable854private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {855 Row(856 modifier = Modifier857 .fillMaxWidth()858 .clickable(onClick = onClick)859 .padding(vertical = 9.dp),860 verticalAlignment = Alignment.CenterVertically,861 ) {862 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)863 Column(modifier = Modifier.padding(start = 12.dp)) {864 Text(865 text = entry.title,866 color = peachColors.T0,867 fontFamily = PeachType.Body,868 fontSize = 15.sp,869 maxLines = 1,870 overflow = TextOverflow.Ellipsis,871 )872 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""873 if (sub.isNotBlank()) {874 Text(875 text = sub,876 color = peachColors.T2,877 fontFamily = PeachType.Mono,878 fontSize = 11.sp,879 maxLines = 1,880 overflow = TextOverflow.Ellipsis,881 )882 }883 }884 }885}886887888private const val SEARCH_DEBOUNCE_MS = 200L