1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Build5import android.os.Bundle6import android.service.autofill.Dataset7import android.view.autofill.AutofillId8import android.view.autofill.AutofillManager9import androidx.activity.compose.setContent10import androidx.compose.foundation.background11import androidx.compose.foundation.clickable12import androidx.compose.foundation.layout.Arrangement13import androidx.compose.foundation.layout.Box14import androidx.compose.foundation.layout.Column15import androidx.compose.foundation.layout.Row16import androidx.compose.foundation.layout.fillMaxWidth17import androidx.compose.foundation.layout.padding18import androidx.compose.foundation.layout.size19import androidx.compose.foundation.lazy.LazyColumn20import androidx.compose.foundation.lazy.items21import androidx.compose.foundation.shape.RoundedCornerShape22import androidx.compose.material3.ExperimentalMaterial3Api23import androidx.compose.material3.ModalBottomSheet24import androidx.compose.material3.Text25import androidx.compose.material3.rememberModalBottomSheetState26import androidx.compose.foundation.text.BasicTextField27import androidx.compose.foundation.text.KeyboardActions28import androidx.compose.foundation.text.KeyboardOptions29import androidx.compose.runtime.Composable30import androidx.compose.runtime.CompositionLocalProvider31import androidx.compose.runtime.LaunchedEffect32import androidx.compose.runtime.getValue33import androidx.compose.runtime.mutableStateOf34import androidx.compose.runtime.remember35import androidx.compose.runtime.rememberCoroutineScope36import androidx.compose.runtime.setValue37import androidx.compose.ui.graphics.SolidColor38import androidx.compose.ui.text.TextStyle39import androidx.compose.ui.text.input.ImeAction40import androidx.compose.ui.text.input.KeyboardType41import androidx.compose.ui.text.input.PasswordVisualTransformation42import androidx.compose.ui.Alignment43import androidx.compose.ui.Modifier44import androidx.compose.ui.text.font.FontStyle45import androidx.compose.ui.text.style.TextOverflow46import androidx.compose.ui.unit.dp47import androidx.compose.ui.unit.sp48import androidx.fragment.app.FragmentActivity49import androidx.lifecycle.lifecycleScope50import com.peachpasswords.BuildConfig51import com.peachpasswords.MainActivity52import com.peachpasswords.shared.extractDomain53import com.peachpasswords.ui.lock.BiometricKeyStore54import com.peachpasswords.ui.lock.Biometrics55import com.peachpasswords.ui.components.FaviconInitials56import com.peachpasswords.ui.components.LocalFavicons57import com.peachpasswords.ui.components.LocalFaviconsEnabled58import com.peachpasswords.ui.components.QaFlags59import com.peachpasswords.ui.components.SecureWindowPolicy60import com.peachpasswords.ui.theme.ACCENT_COLORS61import com.peachpasswords.ui.theme.DEFAULT_ACCENT62import com.peachpasswords.ui.theme.LocalPeachAccent63import com.peachpasswords.ui.theme.peachColors64import com.peachpasswords.ui.theme.PeachTheme65import com.peachpasswords.ui.theme.PeachType66import com.peachpasswords.vault.UnlockResult67import com.peachpasswords.vault.VaultEntry68import com.peachpasswords.vault.isCard69import com.peachpasswords.vault.VaultRepository70import dagger.hilt.android.AndroidEntryPoint71import kotlinx.coroutines.delay72import kotlinx.coroutines.launch73import javax.inject.Inject7475767778798081828384858687888990919293949596@AndroidEntryPoint97class AutofillEntryPickerActivity : FragmentActivity() {9899 @Inject lateinit var vault: VaultRepository100 @Inject lateinit var favicons: com.peachpasswords.ui.components.Favicons101 private var debugSecureLease = false102103 @OptIn(ExperimentalMaterial3Api::class)104 override fun onCreate(savedInstanceState: Bundle?) {105 super.onCreate(savedInstanceState)106107 108 109 110 if (!BuildConfig.DEBUG) {111 SecureWindowPolicy.setUserSecure(window, true)112 } else if (!qaSecureCaptureBypass()) {113 114 115 116 SecureWindowPolicy.acquire(window)117 debugSecureLease = true118 }119120 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)121 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)122 123 124 125 126 val rawParsed = pickerParsedFromInputs(127 origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),128 isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),129 callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),130 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),131 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),132 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),133 otpDigitBoxIds = if (Build.VERSION.SDK_INT >= 33) {134 intent.getParcelableArrayListExtra(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS, AutofillId::class.java).orEmpty()135 } else {136 @Suppress("DEPRECATION")137 intent.getParcelableArrayListExtra<AutofillId>(AutofillIntents.EXTRA_OTP_DIGIT_BOX_IDS).orEmpty()138 },139 cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),140 cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),141 cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),142 cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),143 cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),144 cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),145 )146 val parsed = resolveAutofillOrigin(this, rawParsed)147 if (!parsed.credentialReleaseAllowed) {148 cancel()149 return150 }151 val settings = vault.settings.value152 val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT153154 setContent {155 CompositionLocalProvider(156 LocalFavicons provides favicons,157 LocalFaviconsEnabled provides settings.faviconsEnabled,158 ) {159 PeachTheme(accent = accent, textSize = settings.textSize) {160 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)161 val scope = rememberCoroutineScope()162 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }163 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }164 var error by remember { mutableStateOf<String?>(null) }165 var busy by remember { mutableStateOf(false) }166167 val allEntries = remember(unlocked) {168 if (unlocked) vault.getEntries() else emptyList()169 }170 val matchedEntries = remember(unlocked, allEntries) {171 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()172 }173174 175 176 val identities = remember(unlocked, allEntries, matchedEntries) {177 if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()178 }179180 181 182 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }183 val pickNow: (VaultEntry) -> Unit = { entry ->184 val epoch = unlockedEpoch185 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()186 else returnDataset(parsed, entry)187 }188189 190 191 192 LaunchedEffect(unlocked) {193 if (!unlocked && canBiometric()) {194 tryBiometric(195 onUnlocked = {196 unlocked = true197 unlockedEpoch = vault.unlockedSessionEpoch()198 },199 onFail = { },200 )201 }202 }203204 ModalBottomSheet(205 onDismissRequest = { cancel() },206 sheetState = sheetState,207 containerColor = peachColors.S1,208 dragHandle = { AccentDragHandle() },209 ) {210 PickerBody(211 origin = parsed.origin,212 typedIdentity = typedUsername ?: typedEmail,213 unlocked = unlocked,214 matchedEntries = matchedEntries,215 allEntries = allEntries,216 rememberOffer = rememberOffer,217 onRemember = { entry, domains ->218 rememberOffer = null219 scope.launch {220 val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry221 runCatching {222 vault.updateEntry(223 current.copy(224 additionalUrls = (current.additionalUrls.orEmpty() +225 domains.map { "https://$it" }).distinct(),226 ),227 )228 }229 pickNow(entry)230 }231 },232 onFillOnce = { entry ->233 rememberOffer = null234 pickNow(entry)235 },236 onRememberDismiss = { rememberOffer = null },237 identities = identities,238 onPickIdentity = { identity ->239 val epoch = unlockedEpoch240 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()241 else finishWithDataset(242 AutofillResponseBuilder.identityDatasetFor(243 this@AutofillEntryPickerActivity, parsed, identity,244 ),245 )246 },247 error = error,248 busy = busy,249 canBiometric = canBiometric(),250 onBiometric = {251 tryBiometric(252 onUnlocked = {253 unlocked = true254 unlockedEpoch = vault.unlockedSessionEpoch()255 },256 onFail = {},257 )258 },259 onUnlock = { password ->260 busy = true261 error = null262 scope.launch {263 when (vault.unlock(password)) {264 is UnlockResult.Success -> {265 unlocked = true266 unlockedEpoch = vault.unlockedSessionEpoch()267 }268 UnlockResult.WrongPassword -> error = "Wrong master password."269 else -> error = "Couldn't unlock."270 }271 busy = false272 }273 },274 onPick = { entry ->275 val offer = rememberOfferDomains(entry, parsed)276 if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer277 },278 onCreateNew = { searchText ->279 launchNewEntry(parsed, typedUsername, typedEmail, searchText)280 },281 )282 }283 }284 }285 }286 }287288 override fun onDestroy() {289 if (debugSecureLease) {290 SecureWindowPolicy.release(window)291 debugSecureLease = false292 }293 super.onDestroy()294 }295296 private fun qaSecureCaptureBypass(): Boolean =297 BuildConfig.DEBUG && (298 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||299 QaFlags.disableSecureCaptureForQa300 )301302 private fun canBiometric(): Boolean =303 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)304305 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {306 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()307 Biometrics.authenticateCrypto(308 activity = this,309 title = "Unlock Peach",310 subtitle = "Fill your saved details",311 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),312 onSuccess = { result ->313 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()314 315 316 317 318 lifecycleScope.launch {319 320 321 322 var key: ByteArray? = null323 try {324 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)325 if (key == null) return@launch onFail()326 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()327 } finally {328 key?.let(com.peachpasswords.vault.VaultCrypto::zero)329 }330 }331 },332 onFail = onFail,333 )334 }335336 private fun returnDataset(337 parsed: ParsedStructure,338 entry: VaultEntry,339 ) {340 if (!parsed.credentialReleaseAllowed) {341 cancel()342 return343 }344 val privatePreview = vault.settings.value.hideAutofillPreviews345 val dataset = if (entry.isCard()) {346 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null, privatePreview)347 } else {348 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null, privatePreview)349 }350 finishWithDataset(dataset)351 }352353 private fun finishWithDataset(dataset: Dataset?) {354 if (dataset == null) { cancel(); return }355 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)356 setResult(RESULT_OK, reply)357 finish()358 }359360 361362363364365366 private fun launchNewEntry(367 parsed: ParsedStructure,368 typedUsername: String?,369 typedEmail: String?,370 searchText: String?,371 ) {372 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin373 val intent = Intent(this, MainActivity::class.java).apply {374 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)375 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN, AutofillIntents.issueNewEntryToken())376 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)377 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)378 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)379 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)380 }381 startActivity(intent)382 cancel()383 }384385 private fun cancel() {386 setResult(RESULT_CANCELED)387 finish()388 }389390 @Suppress("DEPRECATION")391 private fun intentAutofillId(key: String): AutofillId? =392 if (android.os.Build.VERSION.SDK_INT >= 33) {393 intent.getParcelableExtra(key, AutofillId::class.java)394 } else {395 intent.getParcelableExtra(key)396 }397398 private companion object {399 const val MAX_ROWS = 12400 }401}402403404405406407408internal fun pickerParsedFromInputs(409 origin: String?,410 isWebOrigin: Boolean,411 callerPackage: String?,412 usernameId: AutofillId? = null,413 passwordId: AutofillId? = null,414 otpId: AutofillId? = null,415 otpDigitBoxIds: List<AutofillId> = emptyList(),416 cardNumberId: AutofillId? = null,417 cardExpMonthId: AutofillId? = null,418 cardExpYearId: AutofillId? = null,419 cardExpDateId: AutofillId? = null,420 cardCvcId: AutofillId? = null,421 cardNameId: AutofillId? = null,422): ParsedStructure = ParsedStructure(423 usernameId = usernameId,424 passwordId = passwordId,425 otpId = otpId,426 cardNumberId = cardNumberId,427 cardExpMonthId = cardExpMonthId,428 cardExpYearId = cardExpYearId,429 cardExpDateId = cardExpDateId,430 cardCvcId = cardCvcId,431 cardNameId = cardNameId,432 otpDigitBoxIds = otpDigitBoxIds,433 webDomain = origin.takeIf { isWebOrigin },434 packageName = if (isWebOrigin) callerPackage else origin,435)436437internal fun autofillPickerEntries(438 entries: List<VaultEntry>,439 parsed: ParsedStructure,440 limit: Int,441): List<VaultEntry> = if (parsed.credentialReleaseAllowed) {442 AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)443} else {444 emptyList()445}446447448449450451452internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {453 if (query.isEmpty()) return true454 val needle = query.lowercase()455 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)456 .any { it.lowercase().contains(needle) }457}458459@Composable460private fun AccentDragHandle() {461 val accent = LocalPeachAccent.current462 Row(463 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),464 horizontalArrangement = Arrangement.Center,465 ) {466 Box(467 modifier = Modifier468 .size(width = 32.dp, height = 4.dp)469 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),470 )471 }472}473474@Composable475private fun PickerBody(476 origin: String?,477 typedIdentity: String?,478 unlocked: Boolean,479 matchedEntries: List<VaultEntry>,480 allEntries: List<VaultEntry>,481 rememberOffer: Pair<VaultEntry, List<String>>?,482 onRemember: (VaultEntry, List<String>) -> Unit,483 onFillOnce: (VaultEntry) -> Unit,484 onRememberDismiss: () -> Unit,485 identities: List<SavedIdentity>,486 onPickIdentity: (SavedIdentity) -> Unit,487 error: String?,488 busy: Boolean,489 canBiometric: Boolean,490 onBiometric: () -> Unit,491 onUnlock: (String) -> Unit,492 onPick: (VaultEntry) -> Unit,493 onCreateNew: (String?) -> Unit,494) {495 val accent = LocalPeachAccent.current496 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {497 Text(498 text = "peach",499 color = accent,500 fontFamily = PeachType.Display,501 fontStyle = FontStyle.Italic,502 fontSize = 15.sp,503 )504 Text(505 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",506 color = peachColors.T2,507 fontFamily = PeachType.Mono,508 fontSize = 9.sp,509 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),510 )511512 if (!unlocked) {513 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)514 } else if (rememberOffer != null) {515 RememberSitePrompt(516 entryTitle = rememberOffer!!.first.title,517 domains = rememberOffer!!.second,518 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },519 onFillOnce = { onFillOnce(rememberOffer!!.first) },520 onBack = onRememberDismiss,521 )522 } else {523 524 525 var rawQuery by remember { mutableStateOf("") }526 var debouncedQuery by remember { mutableStateOf("") }527 LaunchedEffect(rawQuery) {528 delay(SEARCH_DEBOUNCE_MS)529 debouncedQuery = rawQuery530 }531 SearchField(532 value = rawQuery,533 onValueChange = { rawQuery = it },534 placeholder = when {535 matchedEntries.isNotEmpty() -> "search for another entry…"536 else -> "search vault…"537 },538 )539540 val showIdentities =541 matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()542 val visibleEntries = when {543 debouncedQuery.isBlank() -> matchedEntries544 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }545 }546 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()547548 val emptyMessage = when {549 matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->550 "No saved entries match this site."551 showSearchEmpty ->552 "No entries match \"${debouncedQuery.trim()}\"."553 else -> null554 }555 if (emptyMessage != null) {556 Text(557 text = emptyMessage,558 color = peachColors.T1,559 fontFamily = PeachType.Body,560 fontSize = 13.sp,561 modifier = Modifier.padding(vertical = 6.dp),562 )563 }564565 if (showIdentities) {566 IdentitySection(identities = identities, onPick = onPickIdentity)567 }568569 if (visibleEntries.isNotEmpty()) {570 LazyColumn {571 items(visibleEntries, key = { it.id }) { entry ->572 PickerRow(entry, onClick = { onPick(entry) })573 }574 }575 }576577 val createOrigin =578 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin579 CreateNewRow(580 origin = createOrigin,581 typedIdentity = typedIdentity,582 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },583 )584 }585 }586}587588@Composable589private fun SearchField(590 value: String,591 onValueChange: (String) -> Unit,592 placeholder: String,593) {594 val accent = LocalPeachAccent.current595 BasicTextField(596 value = value,597 onValueChange = onValueChange,598 singleLine = true,599 textStyle = TextStyle(600 color = peachColors.T0,601 fontFamily = PeachType.Mono,602 fontSize = 14.sp,603 ),604 cursorBrush = SolidColor(accent),605 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),606 keyboardActions = KeyboardActions(onSearch = { }),607 modifier = Modifier608 .fillMaxWidth()609 .background(peachColors.S2, RoundedCornerShape(8.dp))610 .padding(horizontal = 12.dp, vertical = 10.dp),611 decorationBox = { inner ->612 Box(modifier = Modifier.fillMaxWidth()) {613 if (value.isEmpty()) {614 Text(615 text = placeholder,616 color = peachColors.T2,617 fontFamily = PeachType.Mono,618 fontSize = 12.sp,619 )620 }621 inner()622 }623 },624 )625}626627@Composable628private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {629 val accent = LocalPeachAccent.current630 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"631 else "Create new entry"632 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }633 Row(634 modifier = Modifier635 .fillMaxWidth()636 .clickable(onClick = onClick)637 .padding(vertical = 10.dp),638 verticalAlignment = Alignment.CenterVertically,639 ) {640 Box(641 modifier = Modifier642 .size(34.dp)643 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),644 contentAlignment = Alignment.Center,645 ) {646 Text(647 text = "+",648 color = accent,649 fontFamily = PeachType.Display,650 fontStyle = FontStyle.Italic,651 fontSize = 18.sp,652 )653 }654 Column(modifier = Modifier.padding(start = 12.dp)) {655 Text(656 text = title,657 color = accent,658 fontFamily = PeachType.Body,659 fontSize = 14.sp,660 maxLines = 1,661 overflow = TextOverflow.Ellipsis,662 )663 if (subtitle != null) {664 Text(665 text = "prefill: $subtitle",666 color = peachColors.T2,667 fontFamily = PeachType.Mono,668 fontSize = 10.sp,669 maxLines = 1,670 overflow = TextOverflow.Ellipsis,671 )672 }673 }674 }675}676677678679680681682683@Composable684private fun RememberSitePrompt(685 entryTitle: String,686 domains: List<String>,687 onRemember: () -> Unit,688 onFillOnce: () -> Unit,689 onBack: () -> Unit,690) {691 val accent = LocalPeachAccent.current692 Text(693 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",694 color = peachColors.T0,695 fontFamily = PeachType.Body,696 fontSize = 14.sp,697 )698 Text(699 text = "It will be suggested automatically the next time you fill here.",700 color = peachColors.T2,701 fontFamily = PeachType.Mono,702 fontSize = 10.sp,703 modifier = Modifier.padding(top = 4.dp),704 )705 Row(706 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),707 horizontalArrangement = Arrangement.spacedBy(18.dp),708 verticalAlignment = Alignment.CenterVertically,709 ) {710 Text(711 text = "Remember & fill",712 color = accent,713 fontFamily = PeachType.Mono,714 fontSize = 13.sp,715 modifier = Modifier716 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))717 .clickable(onClick = onRemember)718 .padding(horizontal = 12.dp, vertical = 8.dp),719 )720 Text(721 text = "Just fill",722 color = peachColors.T1,723 fontFamily = PeachType.Mono,724 fontSize = 13.sp,725 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),726 )727 Text(728 text = "Back",729 color = peachColors.T2,730 fontFamily = PeachType.Mono,731 fontSize = 13.sp,732 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),733 )734 }735}736737738739740741742@Composable743private fun IdentitySection(744 identities: List<SavedIdentity>,745 onPick: (SavedIdentity) -> Unit,746) {747 val accent = LocalPeachAccent.current748 var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }749 val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }750751 Text(752 text = "use a saved identity",753 color = peachColors.T2,754 fontFamily = PeachType.Mono,755 fontSize = 9.sp,756 modifier = Modifier.padding(bottom = 8.dp),757 )758 Row(759 horizontalArrangement = Arrangement.spacedBy(8.dp),760 modifier = Modifier.padding(bottom = 10.dp),761 ) {762 for (filterMode in IdentityFilterMode.values()) {763 val active = filterMode == mode764 Text(765 text = filterMode.label,766 color = if (active) peachColors.S1 else peachColors.T2,767 fontFamily = PeachType.Mono,768 fontSize = 10.sp,769 modifier = Modifier770 .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))771 .clickable { mode = filterMode }772 .padding(horizontal = 10.dp, vertical = 5.dp),773 )774 }775 }776 LazyColumn {777 items(visible, key = { it.type.name + ":" + it.value }) { identity ->778 IdentityRow(identity, onClick = { onPick(identity) })779 }780 }781}782783@Composable784private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {785 Column(786 modifier = Modifier787 .fillMaxWidth()788 .clickable(onClick = onClick)789 .padding(vertical = 8.dp),790 ) {791 Text(792 text = identity.value,793 color = peachColors.T0,794 fontFamily = PeachType.Body,795 fontSize = 13.sp,796 maxLines = 1,797 overflow = TextOverflow.Ellipsis,798 )799 val meta = listOfNotNull(800 identity.exampleDomain,801 if (identity.type == IdentityType.EMAIL) "email" else "username",802 ).joinToString(" \u00b7 ")803 Text(804 text = meta,805 color = peachColors.T2,806 fontFamily = PeachType.Mono,807 fontSize = 9.sp,808 maxLines = 1,809 overflow = TextOverflow.Ellipsis,810 )811 }812}813814@Composable815private fun UnlockBody(816 error: String?,817 busy: Boolean,818 canBiometric: Boolean,819 onBiometric: () -> Unit,820 onUnlock: (String) -> Unit,821) {822 val accent = LocalPeachAccent.current823 var password by remember { mutableStateOf("") }824825 Text(826 text = "Unlock to fill",827 color = peachColors.T0,828 fontFamily = PeachType.Body,829 fontSize = 15.sp,830 modifier = Modifier.padding(bottom = 10.dp),831 )832 BasicTextField(833 value = password,834 onValueChange = { password = it },835 singleLine = true,836 enabled = !busy,837 textStyle = TextStyle(838 color = peachColors.T0,839 fontFamily = PeachType.Mono,840 fontSize = 16.sp,841 ),842 cursorBrush = SolidColor(accent),843 visualTransformation = PasswordVisualTransformation(),844 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),845 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),846 modifier = Modifier847 .fillMaxWidth()848 .background(peachColors.S2, RoundedCornerShape(8.dp))849 .padding(horizontal = 12.dp, vertical = 12.dp),850 )851 if (error != null) {852 Text(853 text = error,854 color = peachColors.Breach,855 fontFamily = PeachType.Body,856 fontSize = 12.sp,857 modifier = Modifier.padding(top = 8.dp),858 )859 }860 Row(861 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),862 horizontalArrangement = Arrangement.spacedBy(20.dp),863 verticalAlignment = Alignment.CenterVertically,864 ) {865 if (canBiometric) {866 Text(867 text = "Use biometrics",868 color = accent,869 fontFamily = PeachType.Mono,870 fontSize = 13.sp,871 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),872 )873 }874 Text(875 text = if (busy) "Unlocking…" else "Unlock",876 color = if (password.isEmpty() || busy) peachColors.T2 else accent,877 fontFamily = PeachType.Mono,878 fontSize = 13.sp,879 modifier = Modifier880 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }881 .padding(vertical = 8.dp),882 )883 }884}885886@Composable887private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {888 Row(889 modifier = Modifier890 .fillMaxWidth()891 .clickable(onClick = onClick)892 .padding(vertical = 9.dp),893 verticalAlignment = Alignment.CenterVertically,894 ) {895 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)896 Column(modifier = Modifier.padding(start = 12.dp)) {897 Text(898 text = entry.title,899 color = peachColors.T0,900 fontFamily = PeachType.Body,901 fontSize = 15.sp,902 maxLines = 1,903 overflow = TextOverflow.Ellipsis,904 )905 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""906 if (sub.isNotBlank()) {907 Text(908 text = sub,909 color = peachColors.T2,910 fontFamily = PeachType.Mono,911 fontSize = 11.sp,912 maxLines = 1,913 overflow = TextOverflow.Ellipsis,914 )915 }916 }917 }918}919920921private const val SEARCH_DEBOUNCE_MS = 200L