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 com.peachpasswords.vault.faviconsAllowed71import dagger.hilt.android.AndroidEntryPoint72import kotlinx.coroutines.delay73import kotlinx.coroutines.launch74import javax.inject.Inject7576777879808182838485868788899091929394959697@AndroidEntryPoint98class AutofillEntryPickerActivity : FragmentActivity() {99100 @Inject lateinit var vault: VaultRepository101 @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons102 private var debugSecureLease = false103104 @OptIn(ExperimentalMaterial3Api::class)105 override fun onCreate(savedInstanceState: Bundle?) {106 super.onCreate(savedInstanceState)107108 109 110 111 if (!BuildConfig.DEBUG) {112 SecureWindowPolicy.setUserSecure(window, true)113 } else if (!qaSecureCaptureBypass()) {114 115 116 117 SecureWindowPolicy.acquire(window)118 debugSecureLease = true119 }120121 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)122 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)123 124 125 126 127 val rawParsed = pickerParsedFromInputs(128 origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),129 isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),130 callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),131 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),132 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),133 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),134 otpDigitBoxIds = if (Build.VERSION.SDK_INT >= 33) {135 intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS, AutofillId::class.java).orEmpty()136 } else {137 @Suppress("DEPRECATION")138 intent.getParcelableArrayListExtra<AutofillId>(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS).orEmpty()139 },140 cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),141 cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),142 cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),143 cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),144 cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),145 cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),146 )147 val parsed = resolveAutofillOrigin(this, rawParsed)148 if (!parsed.credentialReleaseAllowed) {149 cancel()150 return151 }152 val settings = vault.settings.value153 val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT154155 setContent {156 CompositionLocalProvider(157 LocalFavicons provides favicons,158 LocalFaviconsEnabled provides faviconsAllowed(settings),159 ) {160 PeachTheme(accent = accent, textSize = settings.textSize) {161 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)162 val scope = rememberCoroutineScope()163 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }164 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }165 var error by remember { mutableStateOf<String?>(null) }166 var busy by remember { mutableStateOf(false) }167168 val allEntries = remember(unlocked) {169 if (unlocked) vault.getEntries() else emptyList()170 }171 val matchedEntries = remember(unlocked, allEntries) {172 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()173 }174175 176 177 val identities = remember(unlocked, allEntries, matchedEntries) {178 if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()179 }180181 182 183 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }184 val pickNow: (VaultEntry) -> Unit = { entry ->185 val epoch = unlockedEpoch186 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()187 else returnDataset(parsed, entry)188 }189190 191 192 193 LaunchedEffect(unlocked) {194 if (!unlocked && canBiometric()) {195 tryBiometric(196 onUnlocked = {197 unlocked = true198 unlockedEpoch = vault.unlockedSessionEpoch()199 },200 onFail = { },201 )202 }203 }204205 ModalBottomSheet(206 onDismissRequest = { cancel() },207 sheetState = sheetState,208 containerColor = peachColors.S1,209 dragHandle = { AccentDragHandle() },210 ) {211 PickerBody(212 origin = parsed.origin,213 typedIdentity = typedUsername ?: typedEmail,214 unlocked = unlocked,215 matchedEntries = matchedEntries,216 allEntries = allEntries,217 rememberOffer = rememberOffer,218 onRemember = { entry, domains ->219 rememberOffer = null220 scope.launch {221 val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry222 runCatching {223 vault.updateEntry(224 current.copy(225 additionalUrls = (current.additionalUrls.orEmpty() +226 domains.map { "https://$it" }).distinct(),227 ),228 )229 }230 pickNow(entry)231 }232 },233 onFillOnce = { entry ->234 rememberOffer = null235 pickNow(entry)236 },237 onRememberDismiss = { rememberOffer = null },238 identities = identities,239 onPickIdentity = { identity ->240 val epoch = unlockedEpoch241 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()242 else finishWithDataset(243 AutofillResponseBuilder.identityDatasetFor(244 this@AutofillEntryPickerActivity, parsed, identity,245 ),246 )247 },248 error = error,249 busy = busy,250 canBiometric = canBiometric(),251 onBiometric = {252 tryBiometric(253 onUnlocked = {254 unlocked = true255 unlockedEpoch = vault.unlockedSessionEpoch()256 },257 onFail = {},258 )259 },260 onUnlock = { password ->261 busy = true262 error = null263 scope.launch {264 when (vault.unlock(password)) {265 is UnlockResult.Success -> {266 unlocked = true267 unlockedEpoch = vault.unlockedSessionEpoch()268 }269 UnlockResult.WrongPassword -> error = "Wrong master password."270 else -> error = "Couldn't unlock."271 }272 busy = false273 }274 },275 onPick = { entry ->276 val offer = rememberOfferDomains(entry, parsed)277 if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer278 },279 onCreateNew = { searchText ->280 launchNewEntry(parsed, typedUsername, typedEmail, searchText)281 },282 )283 }284 }285 }286 }287 }288289 override fun onDestroy() {290 if (debugSecureLease) {291 SecureWindowPolicy.release(window)292 debugSecureLease = false293 }294 super.onDestroy()295 }296297 private fun qaSecureCaptureBypass(): Boolean =298 BuildConfig.DEBUG && (299 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||300 QaFlags.disableSecureCaptureForQa301 )302303 private fun canBiometric(): Boolean =304 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)305306 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {307 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()308 Biometrics.authenticateCrypto(309 activity = this,310 title = "Unlock Peach",311 subtitle = "Fill your saved details",312 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),313 onSuccess = { result ->314 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()315 316 317 318 319 lifecycleScope.launch {320 321 322 323 var key: ByteArray? = null324 try {325 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)326 if (key == null) return@launch onFail()327 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()328 } finally {329 key?.let(com.peachpasswords.vault.VaultCrypto::zero)330 }331 }332 },333 onFail = onFail,334 )335 }336337 private fun returnDataset(338 parsed: ParsedStructure,339 entry: VaultEntry,340 ) {341 if (!parsed.credentialReleaseAllowed) {342 cancel()343 return344 }345 val privatePreview = vault.settings.value.hideAutofillPreviews346 val dataset = if (entry.isCard()) {347 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null, privatePreview)348 } else {349 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null, privatePreview)350 }351 finishWithDataset(dataset)352 }353354 private fun finishWithDataset(dataset: Dataset?) {355 if (dataset == null) { cancel(); return }356 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)357 setResult(RESULT_OK, reply)358 finish()359 }360361 362363364365366367 private fun launchNewEntry(368 parsed: ParsedStructure,369 typedUsername: String?,370 typedEmail: String?,371 searchText: String?,372 ) {373 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin374 val intent = Intent(this, MainActivity::class.java).apply {375 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)376 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN, AutofillIntents.issueNewEntryToken())377 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)378 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)379 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)380 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)381 }382 startActivity(intent)383 cancel()384 }385386 private fun cancel() {387 setResult(RESULT_CANCELED)388 finish()389 }390391 @Suppress("DEPRECATION")392 private fun intentAutofillId(key: String): AutofillId? =393 if (android.os.Build.VERSION.SDK_INT >= 33) {394 intent.getParcelableExtra(key, AutofillId::class.java)395 } else {396 intent.getParcelableExtra(key)397 }398399 private companion object {400 const val MAX_ROWS = 12401 }402}403404405406407408409internal fun pickerParsedFromInputs(410 origin: String?,411 isWebOrigin: Boolean,412 callerPackage: String?,413 usernameId: AutofillId? = null,414 passwordId: AutofillId? = null,415 otpId: AutofillId? = null,416 otpDigitBoxIds: List<AutofillId> = emptyList(),417 cardNumberId: AutofillId? = null,418 cardExpMonthId: AutofillId? = null,419 cardExpYearId: AutofillId? = null,420 cardExpDateId: AutofillId? = null,421 cardCvcId: AutofillId? = null,422 cardNameId: AutofillId? = null,423): ParsedStructure = ParsedStructure(424 usernameId = usernameId,425 passwordId = passwordId,426 otpId = otpId,427 cardNumberId = cardNumberId,428 cardExpMonthId = cardExpMonthId,429 cardExpYearId = cardExpYearId,430 cardExpDateId = cardExpDateId,431 cardCvcId = cardCvcId,432 cardNameId = cardNameId,433 otpDigitBoxIds = otpDigitBoxIds,434 webDomain = origin.takeIf { isWebOrigin },435 packageName = if (isWebOrigin) callerPackage else origin,436)437438internal fun autofillPickerEntries(439 entries: List<VaultEntry>,440 parsed: ParsedStructure,441 limit: Int,442): List<VaultEntry> = if (parsed.credentialReleaseAllowed) {443 AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)444} else {445 emptyList()446}447448449450451452453internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {454 if (query.isEmpty()) return true455 val needle = query.lowercase()456 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)457 .any { it.lowercase().contains(needle) }458}459460@Composable461private fun AccentDragHandle() {462 val accent = LocalPeachAccent.current463 Row(464 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),465 horizontalArrangement = Arrangement.Center,466 ) {467 Box(468 modifier = Modifier469 .size(width = 32.dp, height = 4.dp)470 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),471 )472 }473}474475@Composable476private fun PickerBody(477 origin: String?,478 typedIdentity: String?,479 unlocked: Boolean,480 matchedEntries: List<VaultEntry>,481 allEntries: List<VaultEntry>,482 rememberOffer: Pair<VaultEntry, List<String>>?,483 onRemember: (VaultEntry, List<String>) -> Unit,484 onFillOnce: (VaultEntry) -> Unit,485 onRememberDismiss: () -> Unit,486 identities: List<SavedIdentity>,487 onPickIdentity: (SavedIdentity) -> Unit,488 error: String?,489 busy: Boolean,490 canBiometric: Boolean,491 onBiometric: () -> Unit,492 onUnlock: (String) -> Unit,493 onPick: (VaultEntry) -> Unit,494 onCreateNew: (String?) -> Unit,495) {496 val accent = LocalPeachAccent.current497 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {498 Text(499 text = "peach",500 color = accent,501 fontFamily = PeachType.Display,502 fontStyle = FontStyle.Italic,503 fontSize = 15.sp,504 )505 Text(506 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",507 color = peachColors.T2,508 fontFamily = PeachType.Mono,509 fontSize = 9.sp,510 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),511 )512513 if (!unlocked) {514 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)515 } else if (rememberOffer != null) {516 RememberSitePrompt(517 entryTitle = rememberOffer!!.first.title,518 domains = rememberOffer!!.second,519 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },520 onFillOnce = { onFillOnce(rememberOffer!!.first) },521 onBack = onRememberDismiss,522 )523 } else {524 525 526 var rawQuery by remember { mutableStateOf("") }527 var debouncedQuery by remember { mutableStateOf("") }528 LaunchedEffect(rawQuery) {529 delay(SEARCH_DEBOUNCE_MS)530 debouncedQuery = rawQuery531 }532 SearchField(533 value = rawQuery,534 onValueChange = { rawQuery = it },535 placeholder = when {536 matchedEntries.isNotEmpty() -> "search for another entry…"537 else -> "search vault…"538 },539 )540541 val showIdentities =542 matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()543 val visibleEntries = when {544 debouncedQuery.isBlank() -> matchedEntries545 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }546 }547 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()548549 val emptyMessage = when {550 matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->551 "No saved entries match this site."552 showSearchEmpty ->553 "No entries match \"${debouncedQuery.trim()}\"."554 else -> null555 }556 if (emptyMessage != null) {557 Text(558 text = emptyMessage,559 color = peachColors.T1,560 fontFamily = PeachType.Body,561 fontSize = 13.sp,562 modifier = Modifier.padding(vertical = 6.dp),563 )564 }565566 if (showIdentities) {567 IdentitySection(identities = identities, onPick = onPickIdentity)568 }569570 if (visibleEntries.isNotEmpty()) {571 LazyColumn {572 items(visibleEntries, key = { it.id }) { entry ->573 PickerRow(entry, onClick = { onPick(entry) })574 }575 }576 }577578 val createOrigin =579 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin580 CreateNewRow(581 origin = createOrigin,582 typedIdentity = typedIdentity,583 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },584 )585 }586 }587}588589@Composable590private fun SearchField(591 value: String,592 onValueChange: (String) -> Unit,593 placeholder: String,594) {595 val accent = LocalPeachAccent.current596 BasicTextField(597 value = value,598 onValueChange = onValueChange,599 singleLine = true,600 textStyle = TextStyle(601 color = peachColors.T0,602 fontFamily = PeachType.Mono,603 fontSize = 14.sp,604 ),605 cursorBrush = SolidColor(accent),606 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),607 keyboardActions = KeyboardActions(onSearch = { }),608 modifier = Modifier609 .fillMaxWidth()610 .background(peachColors.S2, RoundedCornerShape(8.dp))611 .padding(horizontal = 12.dp, vertical = 10.dp),612 decorationBox = { inner ->613 Box(modifier = Modifier.fillMaxWidth()) {614 if (value.isEmpty()) {615 Text(616 text = placeholder,617 color = peachColors.T2,618 fontFamily = PeachType.Mono,619 fontSize = 12.sp,620 )621 }622 inner()623 }624 },625 )626}627628@Composable629private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {630 val accent = LocalPeachAccent.current631 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"632 else "Create new entry"633 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }634 Row(635 modifier = Modifier636 .fillMaxWidth()637 .clickable(onClick = onClick)638 .padding(vertical = 10.dp),639 verticalAlignment = Alignment.CenterVertically,640 ) {641 Box(642 modifier = Modifier643 .size(34.dp)644 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),645 contentAlignment = Alignment.Center,646 ) {647 Text(648 text = "+",649 color = accent,650 fontFamily = PeachType.Display,651 fontStyle = FontStyle.Italic,652 fontSize = 18.sp,653 )654 }655 Column(modifier = Modifier.padding(start = 12.dp)) {656 Text(657 text = title,658 color = accent,659 fontFamily = PeachType.Body,660 fontSize = 14.sp,661 maxLines = 1,662 overflow = TextOverflow.Ellipsis,663 )664 if (subtitle != null) {665 Text(666 text = "prefill: $subtitle",667 color = peachColors.T2,668 fontFamily = PeachType.Mono,669 fontSize = 10.sp,670 maxLines = 1,671 overflow = TextOverflow.Ellipsis,672 )673 }674 }675 }676}677678679680681682683684@Composable685private fun RememberSitePrompt(686 entryTitle: String,687 domains: List<String>,688 onRemember: () -> Unit,689 onFillOnce: () -> Unit,690 onBack: () -> Unit,691) {692 val accent = LocalPeachAccent.current693 Text(694 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",695 color = peachColors.T0,696 fontFamily = PeachType.Body,697 fontSize = 14.sp,698 )699 Text(700 text = "It will be suggested automatically the next time you fill here.",701 color = peachColors.T2,702 fontFamily = PeachType.Mono,703 fontSize = 10.sp,704 modifier = Modifier.padding(top = 4.dp),705 )706 Row(707 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),708 horizontalArrangement = Arrangement.spacedBy(18.dp),709 verticalAlignment = Alignment.CenterVertically,710 ) {711 Text(712 text = "Remember & fill",713 color = accent,714 fontFamily = PeachType.Mono,715 fontSize = 13.sp,716 modifier = Modifier717 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))718 .clickable(onClick = onRemember)719 .padding(horizontal = 12.dp, vertical = 8.dp),720 )721 Text(722 text = "Just fill",723 color = peachColors.T1,724 fontFamily = PeachType.Mono,725 fontSize = 13.sp,726 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),727 )728 Text(729 text = "Back",730 color = peachColors.T2,731 fontFamily = PeachType.Mono,732 fontSize = 13.sp,733 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),734 )735 }736}737738739740741742743@Composable744private fun IdentitySection(745 identities: List<SavedIdentity>,746 onPick: (SavedIdentity) -> Unit,747) {748 val accent = LocalPeachAccent.current749 var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }750 val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }751752 Text(753 text = "use a saved identity",754 color = peachColors.T2,755 fontFamily = PeachType.Mono,756 fontSize = 9.sp,757 modifier = Modifier.padding(bottom = 8.dp),758 )759 Row(760 horizontalArrangement = Arrangement.spacedBy(8.dp),761 modifier = Modifier.padding(bottom = 10.dp),762 ) {763 for (filterMode in IdentityFilterMode.values()) {764 val active = filterMode == mode765 Text(766 text = filterMode.label,767 color = if (active) peachColors.S1 else peachColors.T2,768 fontFamily = PeachType.Mono,769 fontSize = 10.sp,770 modifier = Modifier771 .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))772 .clickable { mode = filterMode }773 .padding(horizontal = 10.dp, vertical = 5.dp),774 )775 }776 }777 LazyColumn {778 items(visible, key = { it.type.name + ":" + it.value }) { identity ->779 IdentityRow(identity, onClick = { onPick(identity) })780 }781 }782}783784@Composable785private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {786 Column(787 modifier = Modifier788 .fillMaxWidth()789 .clickable(onClick = onClick)790 .padding(vertical = 8.dp),791 ) {792 Text(793 text = identity.value,794 color = peachColors.T0,795 fontFamily = PeachType.Body,796 fontSize = 13.sp,797 maxLines = 1,798 overflow = TextOverflow.Ellipsis,799 )800 val meta = listOfNotNull(801 identity.exampleDomain,802 if (identity.type == IdentityType.EMAIL) "email" else "username",803 ).joinToString(" \u00b7 ")804 Text(805 text = meta,806 color = peachColors.T2,807 fontFamily = PeachType.Mono,808 fontSize = 9.sp,809 maxLines = 1,810 overflow = TextOverflow.Ellipsis,811 )812 }813}814815@Composable816private fun UnlockBody(817 error: String?,818 busy: Boolean,819 canBiometric: Boolean,820 onBiometric: () -> Unit,821 onUnlock: (String) -> Unit,822) {823 val accent = LocalPeachAccent.current824 var password by remember { mutableStateOf("") }825826 Text(827 text = "Unlock to fill",828 color = peachColors.T0,829 fontFamily = PeachType.Body,830 fontSize = 15.sp,831 modifier = Modifier.padding(bottom = 10.dp),832 )833 BasicTextField(834 value = password,835 onValueChange = { password = it },836 singleLine = true,837 enabled = !busy,838 textStyle = TextStyle(839 color = peachColors.T0,840 fontFamily = PeachType.Mono,841 fontSize = 16.sp,842 ),843 cursorBrush = SolidColor(accent),844 visualTransformation = PasswordVisualTransformation(),845 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),846 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),847 modifier = Modifier848 .fillMaxWidth()849 .background(peachColors.S2, RoundedCornerShape(8.dp))850 .padding(horizontal = 12.dp, vertical = 12.dp),851 )852 if (error != null) {853 Text(854 text = error,855 color = peachColors.Breach,856 fontFamily = PeachType.Body,857 fontSize = 12.sp,858 modifier = Modifier.padding(top = 8.dp),859 )860 }861 Row(862 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),863 horizontalArrangement = Arrangement.spacedBy(20.dp),864 verticalAlignment = Alignment.CenterVertically,865 ) {866 if (canBiometric) {867 Text(868 text = "Use biometrics",869 color = accent,870 fontFamily = PeachType.Mono,871 fontSize = 13.sp,872 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),873 )874 }875 Text(876 text = if (busy) "Unlocking…" else "Unlock",877 color = if (password.isEmpty() || busy) peachColors.T2 else accent,878 fontFamily = PeachType.Mono,879 fontSize = 13.sp,880 modifier = Modifier881 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }882 .padding(vertical = 8.dp),883 )884 }885}886887@Composable888private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {889 Row(890 modifier = Modifier891 .fillMaxWidth()892 .clickable(onClick = onClick)893 .padding(vertical = 9.dp),894 verticalAlignment = Alignment.CenterVertically,895 ) {896 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)897 Column(modifier = Modifier.padding(start = 12.dp)) {898 Text(899 text = entry.title,900 color = peachColors.T0,901 fontFamily = PeachType.Body,902 fontSize = 15.sp,903 maxLines = 1,904 overflow = TextOverflow.Ellipsis,905 )906 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""907 if (sub.isNotBlank()) {908 Text(909 text = sub,910 color = peachColors.T2,911 fontFamily = PeachType.Mono,912 fontSize = 11.sp,913 maxLines = 1,914 overflow = TextOverflow.Ellipsis,915 )916 }917 }918 }919}920921922private const val SEARCH_DEBOUNCE_MS = 200L