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 settings = vault.settings.value137 val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT138139 setContent {140 PeachTheme(accent = accent, textSize = settings.textSize) {141 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)142 val scope = rememberCoroutineScope()143 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }144 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }145 var error by remember { mutableStateOf<String?>(null) }146 var busy by remember { mutableStateOf(false) }147148 val allEntries = remember(unlocked) {149 if (unlocked) vault.getEntries() else emptyList()150 }151 val matchedEntries = remember(unlocked, allEntries) {152 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()153 }154155 156 157 val identities = remember(unlocked, allEntries, matchedEntries) {158 if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()159 }160161 162 163 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }164 val pickNow: (VaultEntry) -> Unit = { entry ->165 val epoch = unlockedEpoch166 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()167 else returnDataset(parsed, entry)168 }169170 171 172 173 LaunchedEffect(unlocked) {174 if (!unlocked && canBiometric()) {175 tryBiometric(176 onUnlocked = {177 unlocked = true178 unlockedEpoch = vault.unlockedSessionEpoch()179 },180 onFail = { },181 )182 }183 }184185 ModalBottomSheet(186 onDismissRequest = { cancel() },187 sheetState = sheetState,188 containerColor = peachColors.S1,189 dragHandle = { AccentDragHandle() },190 ) {191 PickerBody(192 origin = parsed.origin,193 typedIdentity = typedUsername ?: typedEmail,194 unlocked = unlocked,195 matchedEntries = matchedEntries,196 allEntries = allEntries,197 rememberOffer = rememberOffer,198 onRemember = { entry, domains ->199 rememberOffer = null200 scope.launch {201 val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry202 runCatching {203 vault.updateEntry(204 current.copy(205 additionalUrls = (current.additionalUrls.orEmpty() +206 domains.map { "https://$it" }).distinct(),207 ),208 )209 }210 pickNow(entry)211 }212 },213 onFillOnce = { entry ->214 rememberOffer = null215 pickNow(entry)216 },217 onRememberDismiss = { rememberOffer = null },218 identities = identities,219 onPickIdentity = { identity ->220 val epoch = unlockedEpoch221 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()222 else finishWithDataset(223 AutofillResponseBuilder.identityDatasetFor(224 this@AutofillEntryPickerActivity, parsed, identity,225 ),226 )227 },228 error = error,229 busy = busy,230 canBiometric = canBiometric(),231 onBiometric = {232 tryBiometric(233 onUnlocked = {234 unlocked = true235 unlockedEpoch = vault.unlockedSessionEpoch()236 },237 onFail = {},238 )239 },240 onUnlock = { password ->241 busy = true242 error = null243 scope.launch {244 when (vault.unlock(password)) {245 is UnlockResult.Success -> {246 unlocked = true247 unlockedEpoch = vault.unlockedSessionEpoch()248 }249 UnlockResult.WrongPassword -> error = "Wrong master password."250 else -> error = "Couldn't unlock."251 }252 busy = false253 }254 },255 onPick = { entry ->256 val offer = rememberOfferDomains(entry, parsed)257 if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer258 },259 onCreateNew = { searchText ->260 launchNewEntry(parsed, typedUsername, typedEmail, searchText)261 },262 )263 }264 }265 }266 }267268 override fun onDestroy() {269 if (debugSecureLease) {270 SecureWindowPolicy.release(window)271 debugSecureLease = false272 }273 super.onDestroy()274 }275276 private fun qaSecureCaptureBypass(): Boolean =277 BuildConfig.DEBUG && (278 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||279 QaFlags.disableSecureCaptureForQa280 )281282 private fun canBiometric(): Boolean =283 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)284285 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {286 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()287 Biometrics.authenticateCrypto(288 activity = this,289 title = "Unlock Peach",290 subtitle = "Fill your saved details",291 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),292 onSuccess = { result ->293 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()294 295 296 297 298 lifecycleScope.launch {299 300 301 302 var key: ByteArray? = null303 try {304 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)305 if (key == null) return@launch onFail()306 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()307 } finally {308 key?.let(com.peachpasswords.vault.VaultCrypto::zero)309 }310 }311 },312 onFail = onFail,313 )314 }315316 private fun returnDataset(317 parsed: ParsedStructure,318 entry: VaultEntry,319 ) {320 val dataset = if (entry.isCard()) {321 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)322 } else {323 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)324 }325 finishWithDataset(dataset)326 }327328 private fun finishWithDataset(dataset: Dataset?) {329 if (dataset == null) { cancel(); return }330 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)331 setResult(RESULT_OK, reply)332 finish()333 }334335 336337338339340341 private fun launchNewEntry(342 parsed: ParsedStructure,343 typedUsername: String?,344 typedEmail: String?,345 searchText: String?,346 ) {347 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin348 val intent = Intent(this, MainActivity::class.java).apply {349 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)350 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)351 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)352 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)353 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)354 }355 startActivity(intent)356 cancel()357 }358359 private fun cancel() {360 setResult(RESULT_CANCELED)361 finish()362 }363364 @Suppress("DEPRECATION")365 private fun intentAutofillId(key: String): AutofillId? =366 if (android.os.Build.VERSION.SDK_INT >= 33) {367 intent.getParcelableExtra(key, AutofillId::class.java)368 } else {369 intent.getParcelableExtra(key)370 }371372 private companion object {373 const val MAX_ROWS = 12374 }375}376377378379380381382internal fun pickerParsedFromInputs(383 origin: String?,384 isWebOrigin: Boolean,385 callerPackage: String?,386 usernameId: AutofillId? = null,387 passwordId: AutofillId? = null,388 otpId: AutofillId? = null,389 cardNumberId: AutofillId? = null,390 cardExpMonthId: AutofillId? = null,391 cardExpYearId: AutofillId? = null,392 cardExpDateId: AutofillId? = null,393 cardCvcId: AutofillId? = null,394 cardNameId: AutofillId? = null,395): ParsedStructure = ParsedStructure(396 usernameId = usernameId,397 passwordId = passwordId,398 otpId = otpId,399 cardNumberId = cardNumberId,400 cardExpMonthId = cardExpMonthId,401 cardExpYearId = cardExpYearId,402 cardExpDateId = cardExpDateId,403 cardCvcId = cardCvcId,404 cardNameId = cardNameId,405 webDomain = origin.takeIf { isWebOrigin },406 packageName = if (isWebOrigin) callerPackage else origin,407)408409internal fun autofillPickerEntries(410 entries: List<VaultEntry>,411 parsed: ParsedStructure,412 limit: Int,413): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)414415416417418419420internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {421 if (query.isEmpty()) return true422 val needle = query.lowercase()423 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)424 .any { it.lowercase().contains(needle) }425}426427@Composable428private fun AccentDragHandle() {429 val accent = LocalPeachAccent.current430 Row(431 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),432 horizontalArrangement = Arrangement.Center,433 ) {434 Box(435 modifier = Modifier436 .size(width = 32.dp, height = 4.dp)437 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),438 )439 }440}441442@Composable443private fun PickerBody(444 origin: String?,445 typedIdentity: String?,446 unlocked: Boolean,447 matchedEntries: List<VaultEntry>,448 allEntries: List<VaultEntry>,449 rememberOffer: Pair<VaultEntry, List<String>>?,450 onRemember: (VaultEntry, List<String>) -> Unit,451 onFillOnce: (VaultEntry) -> Unit,452 onRememberDismiss: () -> Unit,453 identities: List<SavedIdentity>,454 onPickIdentity: (SavedIdentity) -> Unit,455 error: String?,456 busy: Boolean,457 canBiometric: Boolean,458 onBiometric: () -> Unit,459 onUnlock: (String) -> Unit,460 onPick: (VaultEntry) -> Unit,461 onCreateNew: (String?) -> Unit,462) {463 val accent = LocalPeachAccent.current464 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {465 Text(466 text = "peach",467 color = accent,468 fontFamily = PeachType.Display,469 fontStyle = FontStyle.Italic,470 fontSize = 15.sp,471 )472 Text(473 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",474 color = peachColors.T2,475 fontFamily = PeachType.Mono,476 fontSize = 9.sp,477 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),478 )479480 if (!unlocked) {481 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)482 } else if (rememberOffer != null) {483 RememberSitePrompt(484 entryTitle = rememberOffer!!.first.title,485 domains = rememberOffer!!.second,486 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },487 onFillOnce = { onFillOnce(rememberOffer!!.first) },488 onBack = onRememberDismiss,489 )490 } else {491 492 493 var rawQuery by remember { mutableStateOf("") }494 var debouncedQuery by remember { mutableStateOf("") }495 LaunchedEffect(rawQuery) {496 delay(SEARCH_DEBOUNCE_MS)497 debouncedQuery = rawQuery498 }499 SearchField(500 value = rawQuery,501 onValueChange = { rawQuery = it },502 placeholder = when {503 matchedEntries.isNotEmpty() -> "search for another entry…"504 else -> "search vault…"505 },506 )507508 val showIdentities =509 matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()510 val visibleEntries = when {511 debouncedQuery.isBlank() -> matchedEntries512 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }513 }514 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()515516 val emptyMessage = when {517 matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->518 "No saved entries match this site."519 showSearchEmpty ->520 "No entries match \"${debouncedQuery.trim()}\"."521 else -> null522 }523 if (emptyMessage != null) {524 Text(525 text = emptyMessage,526 color = peachColors.T1,527 fontFamily = PeachType.Body,528 fontSize = 13.sp,529 modifier = Modifier.padding(vertical = 6.dp),530 )531 }532533 if (showIdentities) {534 IdentitySection(identities = identities, onPick = onPickIdentity)535 }536537 if (visibleEntries.isNotEmpty()) {538 LazyColumn {539 items(visibleEntries, key = { it.id }) { entry ->540 PickerRow(entry, onClick = { onPick(entry) })541 }542 }543 }544545 val createOrigin =546 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin547 CreateNewRow(548 origin = createOrigin,549 typedIdentity = typedIdentity,550 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },551 )552 }553 }554}555556@Composable557private fun SearchField(558 value: String,559 onValueChange: (String) -> Unit,560 placeholder: String,561) {562 val accent = LocalPeachAccent.current563 BasicTextField(564 value = value,565 onValueChange = onValueChange,566 singleLine = true,567 textStyle = TextStyle(568 color = peachColors.T0,569 fontFamily = PeachType.Mono,570 fontSize = 14.sp,571 ),572 cursorBrush = SolidColor(accent),573 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),574 keyboardActions = KeyboardActions(onSearch = { }),575 modifier = Modifier576 .fillMaxWidth()577 .background(peachColors.S2, RoundedCornerShape(8.dp))578 .padding(horizontal = 12.dp, vertical = 10.dp),579 decorationBox = { inner ->580 Box(modifier = Modifier.fillMaxWidth()) {581 if (value.isEmpty()) {582 Text(583 text = placeholder,584 color = peachColors.T2,585 fontFamily = PeachType.Mono,586 fontSize = 12.sp,587 )588 }589 inner()590 }591 },592 )593}594595@Composable596private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {597 val accent = LocalPeachAccent.current598 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"599 else "Create new entry"600 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }601 Row(602 modifier = Modifier603 .fillMaxWidth()604 .clickable(onClick = onClick)605 .padding(vertical = 10.dp),606 verticalAlignment = Alignment.CenterVertically,607 ) {608 Box(609 modifier = Modifier610 .size(34.dp)611 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),612 contentAlignment = Alignment.Center,613 ) {614 Text(615 text = "+",616 color = accent,617 fontFamily = PeachType.Display,618 fontStyle = FontStyle.Italic,619 fontSize = 18.sp,620 )621 }622 Column(modifier = Modifier.padding(start = 12.dp)) {623 Text(624 text = title,625 color = accent,626 fontFamily = PeachType.Body,627 fontSize = 14.sp,628 maxLines = 1,629 overflow = TextOverflow.Ellipsis,630 )631 if (subtitle != null) {632 Text(633 text = "prefill: $subtitle",634 color = peachColors.T2,635 fontFamily = PeachType.Mono,636 fontSize = 10.sp,637 maxLines = 1,638 overflow = TextOverflow.Ellipsis,639 )640 }641 }642 }643}644645646647648649650651@Composable652private fun RememberSitePrompt(653 entryTitle: String,654 domains: List<String>,655 onRemember: () -> Unit,656 onFillOnce: () -> Unit,657 onBack: () -> Unit,658) {659 val accent = LocalPeachAccent.current660 Text(661 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",662 color = peachColors.T0,663 fontFamily = PeachType.Body,664 fontSize = 14.sp,665 )666 Text(667 text = "It will be suggested automatically the next time you fill here.",668 color = peachColors.T2,669 fontFamily = PeachType.Mono,670 fontSize = 10.sp,671 modifier = Modifier.padding(top = 4.dp),672 )673 Row(674 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),675 horizontalArrangement = Arrangement.spacedBy(18.dp),676 verticalAlignment = Alignment.CenterVertically,677 ) {678 Text(679 text = "Remember & fill",680 color = accent,681 fontFamily = PeachType.Mono,682 fontSize = 13.sp,683 modifier = Modifier684 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))685 .clickable(onClick = onRemember)686 .padding(horizontal = 12.dp, vertical = 8.dp),687 )688 Text(689 text = "Just fill",690 color = peachColors.T1,691 fontFamily = PeachType.Mono,692 fontSize = 13.sp,693 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),694 )695 Text(696 text = "Back",697 color = peachColors.T2,698 fontFamily = PeachType.Mono,699 fontSize = 13.sp,700 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),701 )702 }703}704705706707708709710@Composable711private fun IdentitySection(712 identities: List<SavedIdentity>,713 onPick: (SavedIdentity) -> Unit,714) {715 val accent = LocalPeachAccent.current716 var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }717 val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }718719 Text(720 text = "use a saved identity",721 color = peachColors.T2,722 fontFamily = PeachType.Mono,723 fontSize = 9.sp,724 modifier = Modifier.padding(bottom = 8.dp),725 )726 Row(727 horizontalArrangement = Arrangement.spacedBy(8.dp),728 modifier = Modifier.padding(bottom = 10.dp),729 ) {730 for (filterMode in IdentityFilterMode.values()) {731 val active = filterMode == mode732 Text(733 text = filterMode.label,734 color = if (active) peachColors.S1 else peachColors.T2,735 fontFamily = PeachType.Mono,736 fontSize = 10.sp,737 modifier = Modifier738 .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))739 .clickable { mode = filterMode }740 .padding(horizontal = 10.dp, vertical = 5.dp),741 )742 }743 }744 LazyColumn {745 items(visible, key = { it.type.name + ":" + it.value }) { identity ->746 IdentityRow(identity, onClick = { onPick(identity) })747 }748 }749}750751@Composable752private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {753 Column(754 modifier = Modifier755 .fillMaxWidth()756 .clickable(onClick = onClick)757 .padding(vertical = 8.dp),758 ) {759 Text(760 text = identity.value,761 color = peachColors.T0,762 fontFamily = PeachType.Body,763 fontSize = 13.sp,764 maxLines = 1,765 overflow = TextOverflow.Ellipsis,766 )767 val meta = listOfNotNull(768 identity.exampleDomain,769 if (identity.type == IdentityType.EMAIL) "email" else "username",770 ).joinToString(" \u00b7 ")771 Text(772 text = meta,773 color = peachColors.T2,774 fontFamily = PeachType.Mono,775 fontSize = 9.sp,776 maxLines = 1,777 overflow = TextOverflow.Ellipsis,778 )779 }780}781782@Composable783private fun UnlockBody(784 error: String?,785 busy: Boolean,786 canBiometric: Boolean,787 onBiometric: () -> Unit,788 onUnlock: (String) -> Unit,789) {790 val accent = LocalPeachAccent.current791 var password by remember { mutableStateOf("") }792793 Text(794 text = "Unlock to fill",795 color = peachColors.T0,796 fontFamily = PeachType.Body,797 fontSize = 15.sp,798 modifier = Modifier.padding(bottom = 10.dp),799 )800 BasicTextField(801 value = password,802 onValueChange = { password = it },803 singleLine = true,804 enabled = !busy,805 textStyle = TextStyle(806 color = peachColors.T0,807 fontFamily = PeachType.Mono,808 fontSize = 16.sp,809 ),810 cursorBrush = SolidColor(accent),811 visualTransformation = PasswordVisualTransformation(),812 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),813 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),814 modifier = Modifier815 .fillMaxWidth()816 .background(peachColors.S2, RoundedCornerShape(8.dp))817 .padding(horizontal = 12.dp, vertical = 12.dp),818 )819 if (error != null) {820 Text(821 text = error,822 color = peachColors.Breach,823 fontFamily = PeachType.Body,824 fontSize = 12.sp,825 modifier = Modifier.padding(top = 8.dp),826 )827 }828 Row(829 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),830 horizontalArrangement = Arrangement.spacedBy(20.dp),831 verticalAlignment = Alignment.CenterVertically,832 ) {833 if (canBiometric) {834 Text(835 text = "Use biometrics",836 color = accent,837 fontFamily = PeachType.Mono,838 fontSize = 13.sp,839 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),840 )841 }842 Text(843 text = if (busy) "Unlocking…" else "Unlock",844 color = if (password.isEmpty() || busy) peachColors.T2 else accent,845 fontFamily = PeachType.Mono,846 fontSize = 13.sp,847 modifier = Modifier848 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }849 .padding(vertical = 8.dp),850 )851 }852}853854@Composable855private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {856 Row(857 modifier = Modifier858 .fillMaxWidth()859 .clickable(onClick = onClick)860 .padding(vertical = 9.dp),861 verticalAlignment = Alignment.CenterVertically,862 ) {863 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)864 Column(modifier = Modifier.padding(start = 12.dp)) {865 Text(866 text = entry.title,867 color = peachColors.T0,868 fontFamily = PeachType.Body,869 fontSize = 15.sp,870 maxLines = 1,871 overflow = TextOverflow.Ellipsis,872 )873 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""874 if (sub.isNotBlank()) {875 Text(876 text = sub,877 color = peachColors.T2,878 fontFamily = PeachType.Mono,879 fontSize = 11.sp,880 maxLines = 1,881 overflow = TextOverflow.Ellipsis,882 )883 }884 }885 }886}887888889private const val SEARCH_DEBOUNCE_MS = 200L