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 val settings = vault.settings.value148 val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT149150 setContent {151 CompositionLocalProvider(152 LocalFavicons provides favicons,153 LocalFaviconsEnabled provides settings.faviconsEnabled,154 ) {155 PeachTheme(accent = accent, textSize = settings.textSize) {156 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)157 val scope = rememberCoroutineScope()158 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }159 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }160 var error by remember { mutableStateOf<String?>(null) }161 var busy by remember { mutableStateOf(false) }162163 val allEntries = remember(unlocked) {164 if (unlocked) vault.getEntries() else emptyList()165 }166 val matchedEntries = remember(unlocked, allEntries) {167 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()168 }169170 171 172 val identities = remember(unlocked, allEntries, matchedEntries) {173 if (unlocked && matchedEntries.isEmpty()) deriveIdentities(allEntries) else emptyList()174 }175176 177 178 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }179 val pickNow: (VaultEntry) -> Unit = { entry ->180 val epoch = unlockedEpoch181 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()182 else returnDataset(parsed, entry)183 }184185 186 187 188 LaunchedEffect(unlocked) {189 if (!unlocked && canBiometric()) {190 tryBiometric(191 onUnlocked = {192 unlocked = true193 unlockedEpoch = vault.unlockedSessionEpoch()194 },195 onFail = { },196 )197 }198 }199200 ModalBottomSheet(201 onDismissRequest = { cancel() },202 sheetState = sheetState,203 containerColor = peachColors.S1,204 dragHandle = { AccentDragHandle() },205 ) {206 PickerBody(207 origin = parsed.origin,208 typedIdentity = typedUsername ?: typedEmail,209 unlocked = unlocked,210 matchedEntries = matchedEntries,211 allEntries = allEntries,212 rememberOffer = rememberOffer,213 onRemember = { entry, domains ->214 rememberOffer = null215 scope.launch {216 val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry217 runCatching {218 vault.updateEntry(219 current.copy(220 additionalUrls = (current.additionalUrls.orEmpty() +221 domains.map { "https://$it" }).distinct(),222 ),223 )224 }225 pickNow(entry)226 }227 },228 onFillOnce = { entry ->229 rememberOffer = null230 pickNow(entry)231 },232 onRememberDismiss = { rememberOffer = null },233 identities = identities,234 onPickIdentity = { identity ->235 val epoch = unlockedEpoch236 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()237 else finishWithDataset(238 AutofillResponseBuilder.identityDatasetFor(239 this@AutofillEntryPickerActivity, parsed, identity,240 ),241 )242 },243 error = error,244 busy = busy,245 canBiometric = canBiometric(),246 onBiometric = {247 tryBiometric(248 onUnlocked = {249 unlocked = true250 unlockedEpoch = vault.unlockedSessionEpoch()251 },252 onFail = {},253 )254 },255 onUnlock = { password ->256 busy = true257 error = null258 scope.launch {259 when (vault.unlock(password)) {260 is UnlockResult.Success -> {261 unlocked = true262 unlockedEpoch = vault.unlockedSessionEpoch()263 }264 UnlockResult.WrongPassword -> error = "Wrong master password."265 else -> error = "Couldn't unlock."266 }267 busy = false268 }269 },270 onPick = { entry ->271 val offer = rememberOfferDomains(entry, parsed)272 if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer273 },274 onCreateNew = { searchText ->275 launchNewEntry(parsed, typedUsername, typedEmail, searchText)276 },277 )278 }279 }280 }281 }282 }283284 override fun onDestroy() {285 if (debugSecureLease) {286 SecureWindowPolicy.release(window)287 debugSecureLease = false288 }289 super.onDestroy()290 }291292 private fun qaSecureCaptureBypass(): Boolean =293 BuildConfig.DEBUG && (294 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||295 QaFlags.disableSecureCaptureForQa296 )297298 private fun canBiometric(): Boolean =299 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)300301 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {302 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()303 Biometrics.authenticateCrypto(304 activity = this,305 title = "Unlock Peach",306 subtitle = "Fill your saved details",307 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),308 onSuccess = { result ->309 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()310 311 312 313 314 lifecycleScope.launch {315 316 317 318 var key: ByteArray? = null319 try {320 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)321 if (key == null) return@launch onFail()322 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()323 } finally {324 key?.let(com.peachpasswords.vault.VaultCrypto::zero)325 }326 }327 },328 onFail = onFail,329 )330 }331332 private fun returnDataset(333 parsed: ParsedStructure,334 entry: VaultEntry,335 ) {336 val dataset = if (entry.isCard()) {337 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)338 } else {339 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)340 }341 finishWithDataset(dataset)342 }343344 private fun finishWithDataset(dataset: Dataset?) {345 if (dataset == null) { cancel(); return }346 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)347 setResult(RESULT_OK, reply)348 finish()349 }350351 352353354355356357 private fun launchNewEntry(358 parsed: ParsedStructure,359 typedUsername: String?,360 typedEmail: String?,361 searchText: String?,362 ) {363 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin364 val intent = Intent(this, MainActivity::class.java).apply {365 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)366 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)367 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)368 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)369 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)370 }371 startActivity(intent)372 cancel()373 }374375 private fun cancel() {376 setResult(RESULT_CANCELED)377 finish()378 }379380 @Suppress("DEPRECATION")381 private fun intentAutofillId(key: String): AutofillId? =382 if (android.os.Build.VERSION.SDK_INT >= 33) {383 intent.getParcelableExtra(key, AutofillId::class.java)384 } else {385 intent.getParcelableExtra(key)386 }387388 private companion object {389 const val MAX_ROWS = 12390 }391}392393394395396397398internal fun pickerParsedFromInputs(399 origin: String?,400 isWebOrigin: Boolean,401 callerPackage: String?,402 usernameId: AutofillId? = null,403 passwordId: AutofillId? = null,404 otpId: AutofillId? = null,405 otpDigitBoxIds: List<AutofillId> = emptyList(),406 cardNumberId: AutofillId? = null,407 cardExpMonthId: AutofillId? = null,408 cardExpYearId: AutofillId? = null,409 cardExpDateId: AutofillId? = null,410 cardCvcId: AutofillId? = null,411 cardNameId: AutofillId? = null,412): ParsedStructure = ParsedStructure(413 usernameId = usernameId,414 passwordId = passwordId,415 otpId = otpId,416 cardNumberId = cardNumberId,417 cardExpMonthId = cardExpMonthId,418 cardExpYearId = cardExpYearId,419 cardExpDateId = cardExpDateId,420 cardCvcId = cardCvcId,421 cardNameId = cardNameId,422 otpDigitBoxIds = otpDigitBoxIds,423 webDomain = origin.takeIf { isWebOrigin },424 packageName = if (isWebOrigin) callerPackage else origin,425)426427internal fun autofillPickerEntries(428 entries: List<VaultEntry>,429 parsed: ParsedStructure,430 limit: Int,431): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)432433434435436437438internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {439 if (query.isEmpty()) return true440 val needle = query.lowercase()441 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)442 .any { it.lowercase().contains(needle) }443}444445@Composable446private fun AccentDragHandle() {447 val accent = LocalPeachAccent.current448 Row(449 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),450 horizontalArrangement = Arrangement.Center,451 ) {452 Box(453 modifier = Modifier454 .size(width = 32.dp, height = 4.dp)455 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),456 )457 }458}459460@Composable461private fun PickerBody(462 origin: String?,463 typedIdentity: String?,464 unlocked: Boolean,465 matchedEntries: List<VaultEntry>,466 allEntries: List<VaultEntry>,467 rememberOffer: Pair<VaultEntry, List<String>>?,468 onRemember: (VaultEntry, List<String>) -> Unit,469 onFillOnce: (VaultEntry) -> Unit,470 onRememberDismiss: () -> Unit,471 identities: List<SavedIdentity>,472 onPickIdentity: (SavedIdentity) -> Unit,473 error: String?,474 busy: Boolean,475 canBiometric: Boolean,476 onBiometric: () -> Unit,477 onUnlock: (String) -> Unit,478 onPick: (VaultEntry) -> Unit,479 onCreateNew: (String?) -> Unit,480) {481 val accent = LocalPeachAccent.current482 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {483 Text(484 text = "peach",485 color = accent,486 fontFamily = PeachType.Display,487 fontStyle = FontStyle.Italic,488 fontSize = 15.sp,489 )490 Text(491 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",492 color = peachColors.T2,493 fontFamily = PeachType.Mono,494 fontSize = 9.sp,495 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),496 )497498 if (!unlocked) {499 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)500 } else if (rememberOffer != null) {501 RememberSitePrompt(502 entryTitle = rememberOffer!!.first.title,503 domains = rememberOffer!!.second,504 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },505 onFillOnce = { onFillOnce(rememberOffer!!.first) },506 onBack = onRememberDismiss,507 )508 } else {509 510 511 var rawQuery by remember { mutableStateOf("") }512 var debouncedQuery by remember { mutableStateOf("") }513 LaunchedEffect(rawQuery) {514 delay(SEARCH_DEBOUNCE_MS)515 debouncedQuery = rawQuery516 }517 SearchField(518 value = rawQuery,519 onValueChange = { rawQuery = it },520 placeholder = when {521 matchedEntries.isNotEmpty() -> "search for another entry…"522 else -> "search vault…"523 },524 )525526 val showIdentities =527 matchedEntries.isEmpty() && debouncedQuery.isBlank() && identities.isNotEmpty()528 val visibleEntries = when {529 debouncedQuery.isBlank() -> matchedEntries530 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }531 }532 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()533534 val emptyMessage = when {535 matchedEntries.isEmpty() && debouncedQuery.isBlank() && !showIdentities ->536 "No saved entries match this site."537 showSearchEmpty ->538 "No entries match \"${debouncedQuery.trim()}\"."539 else -> null540 }541 if (emptyMessage != null) {542 Text(543 text = emptyMessage,544 color = peachColors.T1,545 fontFamily = PeachType.Body,546 fontSize = 13.sp,547 modifier = Modifier.padding(vertical = 6.dp),548 )549 }550551 if (showIdentities) {552 IdentitySection(identities = identities, onPick = onPickIdentity)553 }554555 if (visibleEntries.isNotEmpty()) {556 LazyColumn {557 items(visibleEntries, key = { it.id }) { entry ->558 PickerRow(entry, onClick = { onPick(entry) })559 }560 }561 }562563 val createOrigin =564 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin565 CreateNewRow(566 origin = createOrigin,567 typedIdentity = typedIdentity,568 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },569 )570 }571 }572}573574@Composable575private fun SearchField(576 value: String,577 onValueChange: (String) -> Unit,578 placeholder: String,579) {580 val accent = LocalPeachAccent.current581 BasicTextField(582 value = value,583 onValueChange = onValueChange,584 singleLine = true,585 textStyle = TextStyle(586 color = peachColors.T0,587 fontFamily = PeachType.Mono,588 fontSize = 14.sp,589 ),590 cursorBrush = SolidColor(accent),591 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),592 keyboardActions = KeyboardActions(onSearch = { }),593 modifier = Modifier594 .fillMaxWidth()595 .background(peachColors.S2, RoundedCornerShape(8.dp))596 .padding(horizontal = 12.dp, vertical = 10.dp),597 decorationBox = { inner ->598 Box(modifier = Modifier.fillMaxWidth()) {599 if (value.isEmpty()) {600 Text(601 text = placeholder,602 color = peachColors.T2,603 fontFamily = PeachType.Mono,604 fontSize = 12.sp,605 )606 }607 inner()608 }609 },610 )611}612613@Composable614private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {615 val accent = LocalPeachAccent.current616 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"617 else "Create new entry"618 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }619 Row(620 modifier = Modifier621 .fillMaxWidth()622 .clickable(onClick = onClick)623 .padding(vertical = 10.dp),624 verticalAlignment = Alignment.CenterVertically,625 ) {626 Box(627 modifier = Modifier628 .size(34.dp)629 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),630 contentAlignment = Alignment.Center,631 ) {632 Text(633 text = "+",634 color = accent,635 fontFamily = PeachType.Display,636 fontStyle = FontStyle.Italic,637 fontSize = 18.sp,638 )639 }640 Column(modifier = Modifier.padding(start = 12.dp)) {641 Text(642 text = title,643 color = accent,644 fontFamily = PeachType.Body,645 fontSize = 14.sp,646 maxLines = 1,647 overflow = TextOverflow.Ellipsis,648 )649 if (subtitle != null) {650 Text(651 text = "prefill: $subtitle",652 color = peachColors.T2,653 fontFamily = PeachType.Mono,654 fontSize = 10.sp,655 maxLines = 1,656 overflow = TextOverflow.Ellipsis,657 )658 }659 }660 }661}662663664665666667668669@Composable670private fun RememberSitePrompt(671 entryTitle: String,672 domains: List<String>,673 onRemember: () -> Unit,674 onFillOnce: () -> Unit,675 onBack: () -> Unit,676) {677 val accent = LocalPeachAccent.current678 Text(679 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",680 color = peachColors.T0,681 fontFamily = PeachType.Body,682 fontSize = 14.sp,683 )684 Text(685 text = "It will be suggested automatically the next time you fill here.",686 color = peachColors.T2,687 fontFamily = PeachType.Mono,688 fontSize = 10.sp,689 modifier = Modifier.padding(top = 4.dp),690 )691 Row(692 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),693 horizontalArrangement = Arrangement.spacedBy(18.dp),694 verticalAlignment = Alignment.CenterVertically,695 ) {696 Text(697 text = "Remember & fill",698 color = accent,699 fontFamily = PeachType.Mono,700 fontSize = 13.sp,701 modifier = Modifier702 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))703 .clickable(onClick = onRemember)704 .padding(horizontal = 12.dp, vertical = 8.dp),705 )706 Text(707 text = "Just fill",708 color = peachColors.T1,709 fontFamily = PeachType.Mono,710 fontSize = 13.sp,711 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),712 )713 Text(714 text = "Back",715 color = peachColors.T2,716 fontFamily = PeachType.Mono,717 fontSize = 13.sp,718 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),719 )720 }721}722723724725726727728@Composable729private fun IdentitySection(730 identities: List<SavedIdentity>,731 onPick: (SavedIdentity) -> Unit,732) {733 val accent = LocalPeachAccent.current734 var mode by remember { mutableStateOf(IdentityFilterMode.BOTH) }735 val visible = remember(identities, mode) { filterIdentities(identities, "", mode) }736737 Text(738 text = "use a saved identity",739 color = peachColors.T2,740 fontFamily = PeachType.Mono,741 fontSize = 9.sp,742 modifier = Modifier.padding(bottom = 8.dp),743 )744 Row(745 horizontalArrangement = Arrangement.spacedBy(8.dp),746 modifier = Modifier.padding(bottom = 10.dp),747 ) {748 for (filterMode in IdentityFilterMode.values()) {749 val active = filterMode == mode750 Text(751 text = filterMode.label,752 color = if (active) peachColors.S1 else peachColors.T2,753 fontFamily = PeachType.Mono,754 fontSize = 10.sp,755 modifier = Modifier756 .background(if (active) accent else peachColors.S2, RoundedCornerShape(6.dp))757 .clickable { mode = filterMode }758 .padding(horizontal = 10.dp, vertical = 5.dp),759 )760 }761 }762 LazyColumn {763 items(visible, key = { it.type.name + ":" + it.value }) { identity ->764 IdentityRow(identity, onClick = { onPick(identity) })765 }766 }767}768769@Composable770private fun IdentityRow(identity: SavedIdentity, onClick: () -> Unit) {771 Column(772 modifier = Modifier773 .fillMaxWidth()774 .clickable(onClick = onClick)775 .padding(vertical = 8.dp),776 ) {777 Text(778 text = identity.value,779 color = peachColors.T0,780 fontFamily = PeachType.Body,781 fontSize = 13.sp,782 maxLines = 1,783 overflow = TextOverflow.Ellipsis,784 )785 val meta = listOfNotNull(786 identity.exampleDomain,787 if (identity.type == IdentityType.EMAIL) "email" else "username",788 ).joinToString(" \u00b7 ")789 Text(790 text = meta,791 color = peachColors.T2,792 fontFamily = PeachType.Mono,793 fontSize = 9.sp,794 maxLines = 1,795 overflow = TextOverflow.Ellipsis,796 )797 }798}799800@Composable801private fun UnlockBody(802 error: String?,803 busy: Boolean,804 canBiometric: Boolean,805 onBiometric: () -> Unit,806 onUnlock: (String) -> Unit,807) {808 val accent = LocalPeachAccent.current809 var password by remember { mutableStateOf("") }810811 Text(812 text = "Unlock to fill",813 color = peachColors.T0,814 fontFamily = PeachType.Body,815 fontSize = 15.sp,816 modifier = Modifier.padding(bottom = 10.dp),817 )818 BasicTextField(819 value = password,820 onValueChange = { password = it },821 singleLine = true,822 enabled = !busy,823 textStyle = TextStyle(824 color = peachColors.T0,825 fontFamily = PeachType.Mono,826 fontSize = 16.sp,827 ),828 cursorBrush = SolidColor(accent),829 visualTransformation = PasswordVisualTransformation(),830 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),831 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),832 modifier = Modifier833 .fillMaxWidth()834 .background(peachColors.S2, RoundedCornerShape(8.dp))835 .padding(horizontal = 12.dp, vertical = 12.dp),836 )837 if (error != null) {838 Text(839 text = error,840 color = peachColors.Breach,841 fontFamily = PeachType.Body,842 fontSize = 12.sp,843 modifier = Modifier.padding(top = 8.dp),844 )845 }846 Row(847 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),848 horizontalArrangement = Arrangement.spacedBy(20.dp),849 verticalAlignment = Alignment.CenterVertically,850 ) {851 if (canBiometric) {852 Text(853 text = "Use biometrics",854 color = accent,855 fontFamily = PeachType.Mono,856 fontSize = 13.sp,857 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),858 )859 }860 Text(861 text = if (busy) "Unlocking…" else "Unlock",862 color = if (password.isEmpty() || busy) peachColors.T2 else accent,863 fontFamily = PeachType.Mono,864 fontSize = 13.sp,865 modifier = Modifier866 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }867 .padding(vertical = 8.dp),868 )869 }870}871872@Composable873private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {874 Row(875 modifier = Modifier876 .fillMaxWidth()877 .clickable(onClick = onClick)878 .padding(vertical = 9.dp),879 verticalAlignment = Alignment.CenterVertically,880 ) {881 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)882 Column(modifier = Modifier.padding(start = 12.dp)) {883 Text(884 text = entry.title,885 color = peachColors.T0,886 fontFamily = PeachType.Body,887 fontSize = 15.sp,888 maxLines = 1,889 overflow = TextOverflow.Ellipsis,890 )891 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""892 if (sub.isNotBlank()) {893 Text(894 text = sub,895 color = peachColors.T2,896 fontFamily = PeachType.Mono,897 fontSize = 11.sp,898 maxLines = 1,899 overflow = TextOverflow.Ellipsis,900 )901 }902 }903 }904}905906907private const val SEARCH_DEBOUNCE_MS = 200L