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.CompositionLocalProvider30import androidx.compose.runtime.LaunchedEffect31import androidx.compose.runtime.getValue32import androidx.compose.runtime.mutableStateOf33import androidx.compose.runtime.remember34import androidx.compose.runtime.rememberCoroutineScope35import androidx.compose.runtime.setValue36import androidx.compose.ui.graphics.SolidColor37import androidx.compose.ui.text.TextStyle38import androidx.compose.ui.text.input.ImeAction39import androidx.compose.ui.text.input.KeyboardType40import androidx.compose.ui.text.input.PasswordVisualTransformation41import androidx.compose.ui.Alignment42import androidx.compose.ui.Modifier43import androidx.compose.ui.text.font.FontStyle44import androidx.compose.ui.text.style.TextOverflow45import androidx.compose.ui.unit.dp46import androidx.compose.ui.unit.sp47import androidx.fragment.app.FragmentActivity48import androidx.lifecycle.lifecycleScope49import com.peachpasswords.BuildConfig50import com.peachpasswords.MainActivity51import com.peachpasswords.shared.extractDomain52import com.peachpasswords.ui.lock.BiometricKeyStore53import com.peachpasswords.ui.lock.Biometrics54import com.peachpasswords.ui.components.FaviconInitials55import com.peachpasswords.ui.components.LocalFavicons56import com.peachpasswords.ui.components.QaFlags57import com.peachpasswords.ui.components.SecureWindowPolicy58import com.peachpasswords.ui.theme.ACCENT_COLORS59import com.peachpasswords.ui.theme.DEFAULT_ACCENT60import com.peachpasswords.ui.theme.LocalPeachAccent61import com.peachpasswords.ui.theme.peachColors62import com.peachpasswords.ui.theme.PeachTheme63import com.peachpasswords.ui.theme.PeachType64import com.peachpasswords.vault.UnlockResult65import com.peachpasswords.vault.VaultEntry66import com.peachpasswords.vault.isCard67import com.peachpasswords.vault.VaultRepository68import dagger.hilt.android.AndroidEntryPoint69import kotlinx.coroutines.delay70import kotlinx.coroutines.launch71import javax.inject.Inject7273747576777879808182838485868788899091929394@AndroidEntryPoint95class AutofillEntryPickerActivity : FragmentActivity() {9697 @Inject lateinit var vault: VaultRepository98 @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons99 private var debugSecureLease = false100101 @OptIn(ExperimentalMaterial3Api::class)102 override fun onCreate(savedInstanceState: Bundle?) {103 super.onCreate(savedInstanceState)104105 106 107 108 if (!BuildConfig.DEBUG) {109 SecureWindowPolicy.setUserSecure(window, true)110 } else if (!qaSecureCaptureBypass()) {111 112 113 114 SecureWindowPolicy.acquire(window)115 debugSecureLease = true116 }117118 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)119 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)120 121 122 123 124 val rawParsed = pickerParsedFromInputs(125 origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),126 isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),127 callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),128 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),129 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),130 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),131 cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),132 cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),133 cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),134 cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),135 cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),136 cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),137 )138 val parsed = resolveAutofillOrigin(this, rawParsed)139 val settings = vault.settings.value140 val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT141142 setContent {143 CompositionLocalProvider(LocalFavicons provides favicons) {144 PeachTheme(accent = accent, textSize = settings.textSize) {145 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)146 val scope = rememberCoroutineScope()147 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }148 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }149 var error by remember { mutableStateOf<String?>(null) }150 var busy by remember { mutableStateOf(false) }151152 val allEntries = remember(unlocked) {153 if (unlocked) vault.getEntries() else emptyList()154 }155 val matchedEntries = remember(unlocked, allEntries) {156 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()157 }158159 160 161 val identities = remember(unlocked, allEntries, matchedEntries) {162 if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()163 }164165 166 167 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }168 val pickNow: (VaultEntry) -> Unit = { entry ->169 val epoch = unlockedEpoch170 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()171 else returnDataset(parsed, entry)172 }173174 175 176 177 LaunchedEffect(unlocked) {178 if (!unlocked && canBiometric()) {179 tryBiometric(180 onUnlocked = {181 unlocked = true182 unlockedEpoch = vault.unlockedSessionEpoch()183 },184 onFail = { },185 )186 }187 }188189 ModalBottomSheet(190 onDismissRequest = { cancel() },191 sheetState = sheetState,192 containerColor = peachColors.S1,193 dragHandle = { AccentDragHandle() },194 ) {195 PickerBody(196 origin = parsed.origin,197 typedIdentity = typedUsername ?: typedEmail,198 unlocked = unlocked,199 matchedEntries = matchedEntries,200 allEntries = allEntries,201 rememberOffer = rememberOffer,202 onRemember = { entry, domains ->203 rememberOffer = null204 scope.launch {205 val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry206 runCatching {207 vault.updateEntry(208 current.copy(209 additionalUrls = (current.additionalUrls.orEmpty() +210 domains.map { "https://$it" }).distinct(),211 ),212 )213 }214 pickNow(entry)215 }216 },217 onFillOnce = { entry ->218 rememberOffer = null219 pickNow(entry)220 },221 onRememberDismiss = { rememberOffer = null },222 identities = identities,223 onPickIdentity = { identity ->224 val epoch = unlockedEpoch225 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()226 else finishWithDataset(227 AutofillResponseBuilder.identityDatasetFor(228 this@AutofillEntryPickerActivity, parsed, identity,229 ),230 )231 },232 error = error,233 busy = busy,234 canBiometric = canBiometric(),235 onBiometric = {236 tryBiometric(237 onUnlocked = {238 unlocked = true239 unlockedEpoch = vault.unlockedSessionEpoch()240 },241 onFail = {},242 )243 },244 onUnlock = { password ->245 busy = true246 error = null247 scope.launch {248 when (vault.unlock(password)) {249 is UnlockResult.Success -> {250 unlocked = true251 unlockedEpoch = vault.unlockedSessionEpoch()252 }253 UnlockResult.WrongPassword -> error = "Wrong master password."254 else -> error = "Couldn't unlock."255 }256 busy = false257 }258 },259 onPick = { entry ->260 val offer = rememberOfferDomains(entry, parsed)261 if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer262 },263 onCreateNew = { searchText ->264 launchNewEntry(parsed, typedUsername, typedEmail, searchText)265 },266 )267 }268 }269 }270 }271 }272273 override fun onDestroy() {274 if (debugSecureLease) {275 SecureWindowPolicy.release(window)276 debugSecureLease = false277 }278 super.onDestroy()279 }280281 private fun qaSecureCaptureBypass(): Boolean =282 BuildConfig.DEBUG && (283 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||284 QaFlags.disableSecureCaptureForQa285 )286287 private fun canBiometric(): Boolean =288 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)289290 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {291 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()292 Biometrics.authenticateCrypto(293 activity = this,294 title = "Unlock Peach",295 subtitle = "Fill your saved details",296 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),297 onSuccess = { result ->298 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()299 300 301 302 303 lifecycleScope.launch {304 305 306 307 var key: ByteArray? = null308 try {309 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)310 if (key == null) return@launch onFail()311 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()312 } finally {313 key?.let(com.peachpasswords.vault.VaultCrypto::zero)314 }315 }316 },317 onFail = onFail,318 )319 }320321 private fun returnDataset(322 parsed: ParsedStructure,323 entry: VaultEntry,324 ) {325 val dataset = if (entry.isCard()) {326 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)327 } else {328 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)329 }330 finishWithDataset(dataset)331 }332333 private fun finishWithDataset(dataset: Dataset?) {334 if (dataset == null) { cancel(); return }335 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)336 setResult(RESULT_OK, reply)337 finish()338 }339340 341342343344345346 private fun launchNewEntry(347 parsed: ParsedStructure,348 typedUsername: String?,349 typedEmail: String?,350 searchText: String?,351 ) {352 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin353 val intent = Intent(this, MainActivity::class.java).apply {354 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)355 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)356 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)357 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)358 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)359 }360 startActivity(intent)361 cancel()362 }363364 private fun cancel() {365 setResult(RESULT_CANCELED)366 finish()367 }368369 @Suppress("DEPRECATION")370 private fun intentAutofillId(key: String): AutofillId? =371 if (android.os.Build.VERSION.SDK_INT >= 33) {372 intent.getParcelableExtra(key, AutofillId::class.java)373 } else {374 intent.getParcelableExtra(key)375 }376377 private companion object {378 const val MAX_ROWS = 12379 }380}381382383384385386387internal fun pickerParsedFromInputs(388 origin: String?,389 isWebOrigin: Boolean,390 callerPackage: String?,391 usernameId: AutofillId? = null,392 passwordId: AutofillId? = null,393 otpId: AutofillId? = null,394 cardNumberId: AutofillId? = null,395 cardExpMonthId: AutofillId? = null,396 cardExpYearId: AutofillId? = null,397 cardExpDateId: AutofillId? = null,398 cardCvcId: AutofillId? = null,399 cardNameId: AutofillId? = null,400): ParsedStructure = ParsedStructure(401 usernameId = usernameId,402 passwordId = passwordId,403 otpId = otpId,404 cardNumberId = cardNumberId,405 cardExpMonthId = cardExpMonthId,406 cardExpYearId = cardExpYearId,407 cardExpDateId = cardExpDateId,408 cardCvcId = cardCvcId,409 cardNameId = cardNameId,410 webDomain = origin.takeIf { isWebOrigin },411 packageName = if (isWebOrigin) callerPackage else origin,412)413414internal fun autofillPickerEntries(415 entries: List<VaultEntry>,416 parsed: ParsedStructure,417 limit: Int,418): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)419420421422423424425internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {426 if (query.isEmpty()) return true427 val needle = query.lowercase()428 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)429 .any { it.lowercase().contains(needle) }430}431432@Composable433private fun AccentDragHandle() {434 val accent = LocalPeachAccent.current435 Row(436 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),437 horizontalArrangement = Arrangement.Center,438 ) {439 Box(440 modifier = Modifier441 .size(width = 32.dp, height = 4.dp)442 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),443 )444 }445}446447@Composable448private fun PickerBody(449 origin: String?,450 typedIdentity: String?,451 unlocked: Boolean,452 matchedEntries: List<VaultEntry>,453 allEntries: List<VaultEntry>,454 rememberOffer: Pair<VaultEntry, List<String>>?,455 onRemember: (VaultEntry, List<String>) -> Unit,456 onFillOnce: (VaultEntry) -> Unit,457 onRememberDismiss: () -> Unit,458 identities: List<SavedIdentity>,459 onPickIdentity: (SavedIdentity) -> Unit,460 error: String?,461 busy: Boolean,462 canBiometric: Boolean,463 onBiometric: () -> Unit,464 onUnlock: (String) -> Unit,465 onPick: (VaultEntry) -> Unit,466 onCreateNew: (String?) -> Unit,467) {468 val accent = LocalPeachAccent.current469 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {470 Text(471 text = "peach",472 color = accent,473 fontFamily = PeachType.Display,474 fontStyle = FontStyle.Italic,475 fontSize = 15.sp,476 )477 Text(478 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",479 color = peachColors.T2,480 fontFamily = PeachType.Mono,481 fontSize = 9.sp,482 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),483 )484485 if (!unlocked) {486 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)487 } else if (rememberOffer != null) {488 RememberSitePrompt(489 entryTitle = rememberOffer!!.first.title,490 domains = rememberOffer!!.second,491 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },492 onFillOnce = { onFillOnce(rememberOffer!!.first) },493 onBack = onRememberDismiss,494 )495 } else {496 497 498 var rawQuery by remember { mutableStateOf("") }499 var debouncedQuery by remember { mutableStateOf("") }500 LaunchedEffect(rawQuery) {501 delay(SEARCH_DEBOUNCE_MS)502 debouncedQuery = rawQuery503 }504 SearchField(505 value = rawQuery,506 onValueChange = { rawQuery = it },507 placeholder = when {508 matchedEntries.isNotEmpty() -> "search for another entry…"509 else -> "search vault…"510 },511 )512513 val showIdentities =514 matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()515 val visibleEntries = when {516 debouncedQuery.isBlank() -> matchedEntries517 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }518 }519 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()520521 val emptyMessage = when {522 matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->523 "No saved entries match this site."524 showSearchEmpty ->525 "No entries match \"${debouncedQuery.trim()}\"."526 else -> null527 }528 if (emptyMessage != null) {529 Text(530 text = emptyMessage,531 color = peachColors.T1,532 fontFamily = PeachType.Body,533 fontSize = 13.sp,534 modifier = Modifier.padding(vertical = 6.dp),535 )536 }537538 if (showIdentities) {539 IdentitySection(identities = identities, onPick = onPickIdentity)540 }541542 if (visibleEntries.isNotEmpty()) {543 LazyColumn {544 items(visibleEntries, key = { it.id }) { entry ->545 PickerRow(entry, onClick = { onPick(entry) })546 }547 }548 }549550 val createOrigin =551 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin552 CreateNewRow(553 origin = createOrigin,554 typedIdentity = typedIdentity,555 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },556 )557 }558 }559}560561@Composable562private fun SearchField(563 value: String,564 onValueChange: (String) -> Unit,565 placeholder: String,566) {567 val accent = LocalPeachAccent.current568 BasicTextField(569 value = value,570 onValueChange = onValueChange,571 singleLine = true,572 textStyle = TextStyle(573 color = peachColors.T0,574 fontFamily = PeachType.Mono,575 fontSize = 14.sp,576 ),577 cursorBrush = SolidColor(accent),578 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),579 keyboardActions = KeyboardActions(onSearch = { }),580 modifier = Modifier581 .fillMaxWidth()582 .background(peachColors.S2, RoundedCornerShape(8.dp))583 .padding(horizontal = 12.dp, vertical = 10.dp),584 decorationBox = { inner ->585 Box(modifier = Modifier.fillMaxWidth()) {586 if (value.isEmpty()) {587 Text(588 text = placeholder,589 color = peachColors.T2,590 fontFamily = PeachType.Mono,591 fontSize = 12.sp,592 )593 }594 inner()595 }596 },597 )598}599600@Composable601private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {602 val accent = LocalPeachAccent.current603 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"604 else "Create new entry"605 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }606 Row(607 modifier = Modifier608 .fillMaxWidth()609 .clickable(onClick = onClick)610 .padding(vertical = 10.dp),611 verticalAlignment = Alignment.CenterVertically,612 ) {613 Box(614 modifier = Modifier615 .size(34.dp)616 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),617 contentAlignment = Alignment.Center,618 ) {619 Text(620 text = "+",621 color = accent,622 fontFamily = PeachType.Display,623 fontStyle = FontStyle.Italic,624 fontSize = 18.sp,625 )626 }627 Column(modifier = Modifier.padding(start = 12.dp)) {628 Text(629 text = title,630 color = accent,631 fontFamily = PeachType.Body,632 fontSize = 14.sp,633 maxLines = 1,634 overflow = TextOverflow.Ellipsis,635 )636 if (subtitle != null) {637 Text(638 text = "prefill: $subtitle",639 color = peachColors.T2,640 fontFamily = PeachType.Mono,641 fontSize = 10.sp,642 maxLines = 1,643 overflow = TextOverflow.Ellipsis,644 )645 }646 }647 }648}649650651652653654655656@Composable657private fun RememberSitePrompt(658 entryTitle: String,659 domains: List<String>,660 onRemember: () -> Unit,661 onFillOnce: () -> Unit,662 onBack: () -> Unit,663) {664 val accent = LocalPeachAccent.current665 Text(666 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",667 color = peachColors.T0,668 fontFamily = PeachType.Body,669 fontSize = 14.sp,670 )671 Text(672 text = "It will be suggested automatically the next time you fill here.",673 color = peachColors.T2,674 fontFamily = PeachType.Mono,675 fontSize = 10.sp,676 modifier = Modifier.padding(top = 4.dp),677 )678 Row(679 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),680 horizontalArrangement = Arrangement.spacedBy(18.dp),681 verticalAlignment = Alignment.CenterVertically,682 ) {683 Text(684 text = "Remember & fill",685 color = accent,686 fontFamily = PeachType.Mono,687 fontSize = 13.sp,688 modifier = Modifier689 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))690 .clickable(onClick = onRemember)691 .padding(horizontal = 12.dp, vertical = 8.dp),692 )693 Text(694 text = "Just fill",695 color = peachColors.T1,696 fontFamily = PeachType.Mono,697 fontSize = 13.sp,698 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),699 )700 Text(701 text = "Back",702 color = peachColors.T2,703 fontFamily = PeachType.Mono,704 fontSize = 13.sp,705 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),706 )707 }708}709710711712713714715@Composable716private fun IdentitySection(717 identities: List<SavedIdentity>,718 onPick: (SavedIdentity) -> Unit,719) {720 val accent = LocalPeachAccent.current721 var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }722 val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }723724 Text(725 text = "use a saved identity",726 color = peachColors.T2,727 fontFamily = PeachType.Mono,728 fontSize = 9.sp,729 modifier = Modifier.padding(bottom = 8.dp),730 )731 Row(732 horizontalArrangement = Arrangement.spacedBy(8.dp),733 modifier = Modifier.padding(bottom = 10.dp),734 ) {735 for (filterMode in IdentityFilterMode.values()) {736 val active = filterMode == mode737 Text(738 text = filterMode.label,739 color = if (active) peachColors.S1 else peachColors.T2,740 fontFamily = PeachType.Mono,741 fontSize = 10.sp,742 modifier = Modifier743 .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))744 .clickable { mode = filterMode }745 .padding(horizontal = 10.dp, vertical = 5.dp),746 )747 }748 }749 LazyColumn {750 items(visible, key = { it.type.name + ":" + it.value }) { identity ->751 IdentityRow(identity, onClick = { onPick(identity) })752 }753 }754}755756@Composable757private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {758 Column(759 modifier = Modifier760 .fillMaxWidth()761 .clickable(onClick = onClick)762 .padding(vertical = 8.dp),763 ) {764 Text(765 text = identity.value,766 color = peachColors.T0,767 fontFamily = PeachType.Body,768 fontSize = 13.sp,769 maxLines = 1,770 overflow = TextOverflow.Ellipsis,771 )772 val meta = listOfNotNull(773 identity.exampleDomain,774 if (identity.type == IdentityType.EMAIL) "email" else "username",775 ).joinToString(" \u00b7 ")776 Text(777 text = meta,778 color = peachColors.T2,779 fontFamily = PeachType.Mono,780 fontSize = 9.sp,781 maxLines = 1,782 overflow = TextOverflow.Ellipsis,783 )784 }785}786787@Composable788private fun UnlockBody(789 error: String?,790 busy: Boolean,791 canBiometric: Boolean,792 onBiometric: () -> Unit,793 onUnlock: (String) -> Unit,794) {795 val accent = LocalPeachAccent.current796 var password by remember { mutableStateOf("") }797798 Text(799 text = "Unlock to fill",800 color = peachColors.T0,801 fontFamily = PeachType.Body,802 fontSize = 15.sp,803 modifier = Modifier.padding(bottom = 10.dp),804 )805 BasicTextField(806 value = password,807 onValueChange = { password = it },808 singleLine = true,809 enabled = !busy,810 textStyle = TextStyle(811 color = peachColors.T0,812 fontFamily = PeachType.Mono,813 fontSize = 16.sp,814 ),815 cursorBrush = SolidColor(accent),816 visualTransformation = PasswordVisualTransformation(),817 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),818 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),819 modifier = Modifier820 .fillMaxWidth()821 .background(peachColors.S2, RoundedCornerShape(8.dp))822 .padding(horizontal = 12.dp, vertical = 12.dp),823 )824 if (error != null) {825 Text(826 text = error,827 color = peachColors.Breach,828 fontFamily = PeachType.Body,829 fontSize = 12.sp,830 modifier = Modifier.padding(top = 8.dp),831 )832 }833 Row(834 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),835 horizontalArrangement = Arrangement.spacedBy(20.dp),836 verticalAlignment = Alignment.CenterVertically,837 ) {838 if (canBiometric) {839 Text(840 text = "Use biometrics",841 color = accent,842 fontFamily = PeachType.Mono,843 fontSize = 13.sp,844 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),845 )846 }847 Text(848 text = if (busy) "Unlocking…" else "Unlock",849 color = if (password.isEmpty() || busy) peachColors.T2 else accent,850 fontFamily = PeachType.Mono,851 fontSize = 13.sp,852 modifier = Modifier853 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }854 .padding(vertical = 8.dp),855 )856 }857}858859@Composable860private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {861 Row(862 modifier = Modifier863 .fillMaxWidth()864 .clickable(onClick = onClick)865 .padding(vertical = 9.dp),866 verticalAlignment = Alignment.CenterVertically,867 ) {868 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)869 Column(modifier = Modifier.padding(start = 12.dp)) {870 Text(871 text = entry.title,872 color = peachColors.T0,873 fontFamily = PeachType.Body,874 fontSize = 15.sp,875 maxLines = 1,876 overflow = TextOverflow.Ellipsis,877 )878 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""879 if (sub.isNotBlank()) {880 Text(881 text = sub,882 color = peachColors.T2,883 fontFamily = PeachType.Mono,884 fontSize = 11.sp,885 maxLines = 1,886 overflow = TextOverflow.Ellipsis,887 )888 }889 }890 }891}892893894private const val SEARCH_DEBOUNCE_MS = 200L