1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Bundle5import android.view.autofill.AutofillId6import android.view.autofill.AutofillManager7import androidx.activity.compose.setContent8import androidx.compose.foundation.background9import androidx.compose.foundation.clickable10import androidx.compose.foundation.layout.Arrangement11import androidx.compose.foundation.layout.Box12import androidx.compose.foundation.layout.Column13import androidx.compose.foundation.layout.Row14import androidx.compose.foundation.layout.fillMaxWidth15import androidx.compose.foundation.layout.padding16import androidx.compose.foundation.layout.size17import androidx.compose.foundation.lazy.LazyColumn18import androidx.compose.foundation.lazy.items19import androidx.compose.foundation.shape.RoundedCornerShape20import androidx.compose.material3.ExperimentalMaterial3Api21import androidx.compose.material3.ModalBottomSheet22import androidx.compose.material3.Text23import androidx.compose.material3.rememberModalBottomSheetState24import androidx.compose.foundation.text.BasicTextField25import androidx.compose.foundation.text.KeyboardActions26import androidx.compose.foundation.text.KeyboardOptions27import androidx.compose.runtime.Composable28import androidx.compose.runtime.LaunchedEffect29import androidx.compose.runtime.getValue30import androidx.compose.runtime.mutableStateOf31import androidx.compose.runtime.remember32import androidx.compose.runtime.rememberCoroutineScope33import androidx.compose.runtime.setValue34import androidx.compose.ui.graphics.SolidColor35import androidx.compose.ui.text.TextStyle36import androidx.compose.ui.text.input.ImeAction37import androidx.compose.ui.text.input.KeyboardType38import androidx.compose.ui.text.input.PasswordVisualTransformation39import androidx.compose.ui.Alignment40import androidx.compose.ui.Modifier41import androidx.compose.ui.text.font.FontStyle42import androidx.compose.ui.text.style.TextOverflow43import androidx.compose.ui.unit.dp44import androidx.compose.ui.unit.sp45import androidx.fragment.app.FragmentActivity46import androidx.lifecycle.lifecycleScope47import com.peachpasswords.BuildConfig48import com.peachpasswords.MainActivity49import com.peachpasswords.shared.extractDomain50import com.peachpasswords.ui.lock.BiometricKeyStore51import com.peachpasswords.ui.lock.Biometrics52import com.peachpasswords.ui.components.FaviconInitials53import com.peachpasswords.ui.components.QaFlags54import com.peachpasswords.ui.components.SecureWindowPolicy55import com.peachpasswords.ui.theme.ACCENT_COLORS56import com.peachpasswords.ui.theme.DEFAULT_ACCENT57import com.peachpasswords.ui.theme.LocalPeachAccent58import com.peachpasswords.ui.theme.peachColors59import com.peachpasswords.ui.theme.PeachTheme60import com.peachpasswords.ui.theme.PeachType61import com.peachpasswords.vault.UnlockResult62import com.peachpasswords.vault.VaultEntry63import com.peachpasswords.vault.isCard64import com.peachpasswords.vault.VaultRepository65import dagger.hilt.android.AndroidEntryPoint66import kotlinx.coroutines.delay67import kotlinx.coroutines.launch68import javax.inject.Inject6970717273747576777879808182838485868788899091@AndroidEntryPoint92class AutofillEntryPickerActivity : FragmentActivity() {9394 @Inject lateinit var vault: VaultRepository95 private var debugSecureLease = false9697 @OptIn(ExperimentalMaterial3Api::class)98 override fun onCreate(savedInstanceState: Bundle?) {99 super.onCreate(savedInstanceState)100101 102 103 104 if (!BuildConfig.DEBUG) {105 SecureWindowPolicy.setUserSecure(window, true)106 } else if (!qaSecureCaptureBypass()) {107 108 109 110 SecureWindowPolicy.acquire(window)111 debugSecureLease = true112 }113114 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)115 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)116 117 118 119 120 val rawParsed = pickerParsedFromInputs(121 origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),122 isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),123 callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),124 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),125 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),126 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),127 cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),128 cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),129 cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),130 cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),131 cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),132 cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),133 )134 val parsed = resolveAutofillOrigin(this, rawParsed)135 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT136137 setContent {138 PeachTheme(accent = accent) {139 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)140 val scope = rememberCoroutineScope()141 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }142 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }143 var error by remember { mutableStateOf<String?>(null) }144 var busy by remember { mutableStateOf(false) }145146 val allEntries = remember(unlocked) {147 if (unlocked) vault.getEntries() else emptyList()148 }149 val matchedEntries = remember(unlocked, allEntries) {150 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()151 }152153 154 155 var rememberOffer by remember { mutableStateOf<Pair<VaultEntry, List<String>>?>(null) }156 val pickNow: (VaultEntry) -> Unit = { entry ->157 val epoch = unlockedEpoch158 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) cancel()159 else returnDataset(parsed, entry)160 }161162 163 164 165 LaunchedEffect(unlocked) {166 if (!unlocked && canBiometric()) {167 tryBiometric(168 onUnlocked = {169 unlocked = true170 unlockedEpoch = vault.unlockedSessionEpoch()171 },172 onFail = { },173 )174 }175 }176177 ModalBottomSheet(178 onDismissRequest = { cancel() },179 sheetState = sheetState,180 containerColor = peachColors.S1,181 dragHandle = { AccentDragHandle() },182 ) {183 PickerBody(184 origin = parsed.origin,185 typedIdentity = typedUsername ?: typedEmail,186 unlocked = unlocked,187 matchedEntries = matchedEntries,188 allEntries = allEntries,189 rememberOffer = rememberOffer,190 onRemember = { entry, domains ->191 rememberOffer = null192 scope.launch {193 val current = vault.getEntries().firstOrNull { it.id == entry.id } ?: entry194 runCatching {195 vault.updateEntry(196 current.copy(197 additionalUrls = (current.additionalUrls.orEmpty() +198 domains.map { "https://$it" }).distinct(),199 ),200 )201 }202 pickNow(entry)203 }204 },205 onFillOnce = { entry ->206 rememberOffer = null207 pickNow(entry)208 },209 onRememberDismiss = { rememberOffer = null },210 error = error,211 busy = busy,212 canBiometric = canBiometric(),213 onBiometric = {214 tryBiometric(215 onUnlocked = {216 unlocked = true217 unlockedEpoch = vault.unlockedSessionEpoch()218 },219 onFail = {},220 )221 },222 onUnlock = { password ->223 busy = true224 error = null225 scope.launch {226 when (vault.unlock(password)) {227 is UnlockResult.Success -> {228 unlocked = true229 unlockedEpoch = vault.unlockedSessionEpoch()230 }231 UnlockResult.WrongPassword -> error = "Wrong master password."232 else -> error = "Couldn't unlock."233 }234 busy = false235 }236 },237 onPick = { entry ->238 val offer = rememberOfferDomains(entry, parsed)239 if (offer.isEmpty()) pickNow(entry) else rememberOffer = entry to offer240 },241 onCreateNew = { searchText ->242 launchNewEntry(parsed, typedUsername, typedEmail, searchText)243 },244 )245 }246 }247 }248 }249250 override fun onDestroy() {251 if (debugSecureLease) {252 SecureWindowPolicy.release(window)253 debugSecureLease = false254 }255 super.onDestroy()256 }257258 private fun qaSecureCaptureBypass(): Boolean =259 BuildConfig.DEBUG && (260 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||261 QaFlags.disableSecureCaptureForQa262 )263264 private fun canBiometric(): Boolean =265 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)266267 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {268 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()269 Biometrics.authenticateCrypto(270 activity = this,271 title = "Unlock Peach",272 subtitle = "Fill your saved details",273 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),274 onSuccess = { result ->275 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()276 277 278 279 280 lifecycleScope.launch {281 282 283 284 var key: ByteArray? = null285 try {286 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)287 if (key == null) return@launch onFail()288 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()289 } finally {290 key?.let(com.peachpasswords.vault.VaultCrypto::zero)291 }292 }293 },294 onFail = onFail,295 )296 }297298 private fun returnDataset(299 parsed: ParsedStructure,300 entry: VaultEntry,301 ) {302 val dataset = if (entry.isCard()) {303 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)304 } else {305 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)306 }307 if (dataset == null) { cancel(); return }308 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)309 setResult(RESULT_OK, reply)310 finish()311 }312313 314315316317318319 private fun launchNewEntry(320 parsed: ParsedStructure,321 typedUsername: String?,322 typedEmail: String?,323 searchText: String?,324 ) {325 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin326 val intent = Intent(this, MainActivity::class.java).apply {327 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)328 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)329 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)330 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)331 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)332 }333 startActivity(intent)334 cancel()335 }336337 private fun cancel() {338 setResult(RESULT_CANCELED)339 finish()340 }341342 @Suppress("DEPRECATION")343 private fun intentAutofillId(key: String): AutofillId? =344 if (android.os.Build.VERSION.SDK_INT >= 33) {345 intent.getParcelableExtra(key, AutofillId::class.java)346 } else {347 intent.getParcelableExtra(key)348 }349350 private companion object {351 const val MAX_ROWS = 12352 }353}354355356357358359360internal fun pickerParsedFromInputs(361 origin: String?,362 isWebOrigin: Boolean,363 callerPackage: String?,364 usernameId: AutofillId? = null,365 passwordId: AutofillId? = null,366 otpId: AutofillId? = null,367 cardNumberId: AutofillId? = null,368 cardExpMonthId: AutofillId? = null,369 cardExpYearId: AutofillId? = null,370 cardExpDateId: AutofillId? = null,371 cardCvcId: AutofillId? = null,372 cardNameId: AutofillId? = null,373): ParsedStructure = ParsedStructure(374 usernameId = usernameId,375 passwordId = passwordId,376 otpId = otpId,377 cardNumberId = cardNumberId,378 cardExpMonthId = cardExpMonthId,379 cardExpYearId = cardExpYearId,380 cardExpDateId = cardExpDateId,381 cardCvcId = cardCvcId,382 cardNameId = cardNameId,383 webDomain = origin.takeIf { isWebOrigin },384 packageName = if (isWebOrigin) callerPackage else origin,385)386387internal fun autofillPickerEntries(388 entries: List<VaultEntry>,389 parsed: ParsedStructure,390 limit: Int,391): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)392393394395396397398internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {399 if (query.isEmpty()) return true400 val needle = query.lowercase()401 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)402 .any { it.lowercase().contains(needle) }403}404405@Composable406private fun AccentDragHandle() {407 val accent = LocalPeachAccent.current408 Row(409 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),410 horizontalArrangement = Arrangement.Center,411 ) {412 Box(413 modifier = Modifier414 .size(width = 32.dp, height = 4.dp)415 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),416 )417 }418}419420@Composable421private fun PickerBody(422 origin: String?,423 typedIdentity: String?,424 unlocked: Boolean,425 matchedEntries: List<VaultEntry>,426 allEntries: List<VaultEntry>,427 rememberOffer: Pair<VaultEntry, List<String>>?,428 onRemember: (VaultEntry, List<String>) -> Unit,429 onFillOnce: (VaultEntry) -> Unit,430 onRememberDismiss: () -> Unit,431 error: String?,432 busy: Boolean,433 canBiometric: Boolean,434 onBiometric: () -> Unit,435 onUnlock: (String) -> Unit,436 onPick: (VaultEntry) -> Unit,437 onCreateNew: (String?) -> Unit,438) {439 val accent = LocalPeachAccent.current440 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {441 Text(442 text = "peach",443 color = accent,444 fontFamily = PeachType.Display,445 fontStyle = FontStyle.Italic,446 fontSize = 15.sp,447 )448 Text(449 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",450 color = peachColors.T2,451 fontFamily = PeachType.Mono,452 fontSize = 9.sp,453 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),454 )455456 if (!unlocked) {457 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)458 } else if (rememberOffer != null) {459 RememberSitePrompt(460 entryTitle = rememberOffer!!.first.title,461 domains = rememberOffer!!.second,462 onRemember = { onRemember(rememberOffer!!.first, rememberOffer!!.second) },463 onFillOnce = { onFillOnce(rememberOffer!!.first) },464 onBack = onRememberDismiss,465 )466 } else {467 468 469 var rawQuery by remember { mutableStateOf("") }470 var debouncedQuery by remember { mutableStateOf("") }471 LaunchedEffect(rawQuery) {472 delay(SEARCH_DEBOUNCE_MS)473 debouncedQuery = rawQuery474 }475 SearchField(476 value = rawQuery,477 onValueChange = { rawQuery = it },478 placeholder = when {479 matchedEntries.isNotEmpty() -> "search for another entry…"480 else -> "search vault…"481 },482 )483484 val visibleEntries = when {485 debouncedQuery.isBlank() -> matchedEntries486 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }487 }488 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()489490 val emptyMessage = when {491 matchedEntries.isEmpty() && debouncedQuery.isBlank() ->492 "No saved entries match this site."493 showSearchEmpty ->494 "No entries match \"${debouncedQuery.trim()}\"."495 else -> null496 }497 if (emptyMessage != null) {498 Text(499 text = emptyMessage,500 color = peachColors.T1,501 fontFamily = PeachType.Body,502 fontSize = 13.sp,503 modifier = Modifier.padding(vertical = 6.dp),504 )505 }506507 if (visibleEntries.isNotEmpty()) {508 LazyColumn {509 items(visibleEntries, key = { it.id }) { entry ->510 PickerRow(entry, onClick = { onPick(entry) })511 }512 }513 }514515 val createOrigin =516 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin517 CreateNewRow(518 origin = createOrigin,519 typedIdentity = typedIdentity,520 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },521 )522 }523 }524}525526@Composable527private fun SearchField(528 value: String,529 onValueChange: (String) -> Unit,530 placeholder: String,531) {532 val accent = LocalPeachAccent.current533 BasicTextField(534 value = value,535 onValueChange = onValueChange,536 singleLine = true,537 textStyle = TextStyle(538 color = peachColors.T0,539 fontFamily = PeachType.Mono,540 fontSize = 14.sp,541 ),542 cursorBrush = SolidColor(accent),543 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),544 keyboardActions = KeyboardActions(onSearch = { }),545 modifier = Modifier546 .fillMaxWidth()547 .background(peachColors.S2, RoundedCornerShape(8.dp))548 .padding(horizontal = 12.dp, vertical = 10.dp),549 decorationBox = { inner ->550 Box(modifier = Modifier.fillMaxWidth()) {551 if (value.isEmpty()) {552 Text(553 text = placeholder,554 color = peachColors.T2,555 fontFamily = PeachType.Mono,556 fontSize = 12.sp,557 )558 }559 inner()560 }561 },562 )563}564565@Composable566private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {567 val accent = LocalPeachAccent.current568 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"569 else "Create new entry"570 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }571 Row(572 modifier = Modifier573 .fillMaxWidth()574 .clickable(onClick = onClick)575 .padding(vertical = 10.dp),576 verticalAlignment = Alignment.CenterVertically,577 ) {578 Box(579 modifier = Modifier580 .size(34.dp)581 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),582 contentAlignment = Alignment.Center,583 ) {584 Text(585 text = "+",586 color = accent,587 fontFamily = PeachType.Display,588 fontStyle = FontStyle.Italic,589 fontSize = 18.sp,590 )591 }592 Column(modifier = Modifier.padding(start = 12.dp)) {593 Text(594 text = title,595 color = accent,596 fontFamily = PeachType.Body,597 fontSize = 14.sp,598 maxLines = 1,599 overflow = TextOverflow.Ellipsis,600 )601 if (subtitle != null) {602 Text(603 text = "prefill: $subtitle",604 color = peachColors.T2,605 fontFamily = PeachType.Mono,606 fontSize = 10.sp,607 maxLines = 1,608 overflow = TextOverflow.Ellipsis,609 )610 }611 }612 }613}614615616617618619620621@Composable622private fun RememberSitePrompt(623 entryTitle: String,624 domains: List<String>,625 onRemember: () -> Unit,626 onFillOnce: () -> Unit,627 onBack: () -> Unit,628) {629 val accent = LocalPeachAccent.current630 Text(631 text = "Remember ${domains.joinToString(", ")} for \u201c$entryTitle\u201d?",632 color = peachColors.T0,633 fontFamily = PeachType.Body,634 fontSize = 14.sp,635 )636 Text(637 text = "It will be suggested automatically the next time you fill here.",638 color = peachColors.T2,639 fontFamily = PeachType.Mono,640 fontSize = 10.sp,641 modifier = Modifier.padding(top = 4.dp),642 )643 Row(644 modifier = Modifier.fillMaxWidth().padding(top = 16.dp),645 horizontalArrangement = Arrangement.spacedBy(18.dp),646 verticalAlignment = Alignment.CenterVertically,647 ) {648 Text(649 text = "Remember & fill",650 color = accent,651 fontFamily = PeachType.Mono,652 fontSize = 13.sp,653 modifier = Modifier654 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp))655 .clickable(onClick = onRemember)656 .padding(horizontal = 12.dp, vertical = 8.dp),657 )658 Text(659 text = "Just fill",660 color = peachColors.T1,661 fontFamily = PeachType.Mono,662 fontSize = 13.sp,663 modifier = Modifier.clickable(onClick = onFillOnce).padding(vertical = 8.dp),664 )665 Text(666 text = "Back",667 color = peachColors.T2,668 fontFamily = PeachType.Mono,669 fontSize = 13.sp,670 modifier = Modifier.clickable(onClick = onBack).padding(vertical = 8.dp),671 )672 }673}674675@Composable676private fun UnlockBody(677 error: String?,678 busy: Boolean,679 canBiometric: Boolean,680 onBiometric: () -> Unit,681 onUnlock: (String) -> Unit,682) {683 val accent = LocalPeachAccent.current684 var password by remember { mutableStateOf("") }685686 Text(687 text = "Unlock to fill",688 color = peachColors.T0,689 fontFamily = PeachType.Body,690 fontSize = 15.sp,691 modifier = Modifier.padding(bottom = 10.dp),692 )693 BasicTextField(694 value = password,695 onValueChange = { password = it },696 singleLine = true,697 enabled = !busy,698 textStyle = TextStyle(699 color = peachColors.T0,700 fontFamily = PeachType.Mono,701 fontSize = 16.sp,702 ),703 cursorBrush = SolidColor(accent),704 visualTransformation = PasswordVisualTransformation(),705 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),706 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),707 modifier = Modifier708 .fillMaxWidth()709 .background(peachColors.S2, RoundedCornerShape(8.dp))710 .padding(horizontal = 12.dp, vertical = 12.dp),711 )712 if (error != null) {713 Text(714 text = error,715 color = peachColors.Breach,716 fontFamily = PeachType.Body,717 fontSize = 12.sp,718 modifier = Modifier.padding(top = 8.dp),719 )720 }721 Row(722 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),723 horizontalArrangement = Arrangement.spacedBy(20.dp),724 verticalAlignment = Alignment.CenterVertically,725 ) {726 if (canBiometric) {727 Text(728 text = "Use biometrics",729 color = accent,730 fontFamily = PeachType.Mono,731 fontSize = 13.sp,732 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),733 )734 }735 Text(736 text = if (busy) "Unlocking…" else "Unlock",737 color = if (password.isEmpty() || busy) peachColors.T2 else accent,738 fontFamily = PeachType.Mono,739 fontSize = 13.sp,740 modifier = Modifier741 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }742 .padding(vertical = 8.dp),743 )744 }745}746747@Composable748private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {749 Row(750 modifier = Modifier751 .fillMaxWidth()752 .clickable(onClick = onClick)753 .padding(vertical = 9.dp),754 verticalAlignment = Alignment.CenterVertically,755 ) {756 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)757 Column(modifier = Modifier.padding(start = 12.dp)) {758 Text(759 text = entry.title,760 color = peachColors.T0,761 fontFamily = PeachType.Body,762 fontSize = 15.sp,763 maxLines = 1,764 overflow = TextOverflow.Ellipsis,765 )766 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""767 if (sub.isNotBlank()) {768 Text(769 text = sub,770 color = peachColors.T2,771 fontFamily = PeachType.Mono,772 fontSize = 11.sp,773 maxLines = 1,774 overflow = TextOverflow.Ellipsis,775 )776 }777 }778 }779}780781782private const val SEARCH_DEBOUNCE_MS = 200L