1package com.peachpasswords.autofill23import android.content.Intent4import android.os.Bundle5import android.view.WindowManager6import android.view.autofill.AutofillId7import android.view.autofill.AutofillManager8import androidx.activity.compose.setContent9import androidx.compose.foundation.background10import androidx.compose.foundation.clickable11import androidx.compose.foundation.layout.Arrangement12import androidx.compose.foundation.layout.Box13import androidx.compose.foundation.layout.Column14import androidx.compose.foundation.layout.Row15import androidx.compose.foundation.layout.fillMaxWidth16import androidx.compose.foundation.layout.padding17import androidx.compose.foundation.layout.size18import androidx.compose.foundation.lazy.LazyColumn19import androidx.compose.foundation.lazy.items20import androidx.compose.foundation.shape.RoundedCornerShape21import androidx.compose.material3.ExperimentalMaterial3Api22import androidx.compose.material3.ModalBottomSheet23import androidx.compose.material3.Text24import androidx.compose.material3.rememberModalBottomSheetState25import androidx.compose.foundation.text.BasicTextField26import androidx.compose.foundation.text.KeyboardActions27import androidx.compose.foundation.text.KeyboardOptions28import androidx.compose.runtime.Composable29import androidx.compose.runtime.LaunchedEffect30import androidx.compose.runtime.getValue31import androidx.compose.runtime.mutableStateOf32import androidx.compose.runtime.remember33import androidx.compose.runtime.rememberCoroutineScope34import androidx.compose.runtime.setValue35import androidx.compose.ui.graphics.SolidColor36import androidx.compose.ui.text.TextStyle37import androidx.compose.ui.text.input.ImeAction38import androidx.compose.ui.text.input.KeyboardType39import androidx.compose.ui.text.input.PasswordVisualTransformation40import androidx.compose.ui.Alignment41import androidx.compose.ui.Modifier42import androidx.compose.ui.text.font.FontStyle43import androidx.compose.ui.text.style.TextOverflow44import androidx.compose.ui.unit.dp45import androidx.compose.ui.unit.sp46import androidx.fragment.app.FragmentActivity47import androidx.lifecycle.lifecycleScope48import com.peachpasswords.BuildConfig49import com.peachpasswords.MainActivity50import com.peachpasswords.shared.extractDomain51import com.peachpasswords.ui.lock.BiometricKeyStore52import com.peachpasswords.ui.lock.Biometrics53import com.peachpasswords.ui.components.FaviconInitials54import com.peachpasswords.ui.theme.ACCENT_COLORS55import com.peachpasswords.ui.theme.DEFAULT_ACCENT56import com.peachpasswords.ui.theme.LocalPeachAccent57import com.peachpasswords.ui.theme.peachColors58import com.peachpasswords.ui.theme.PeachTheme59import com.peachpasswords.ui.theme.PeachType60import com.peachpasswords.vault.UnlockResult61import com.peachpasswords.vault.VaultEntry62import com.peachpasswords.vault.VaultRepository63import dagger.hilt.android.AndroidEntryPoint64import kotlinx.coroutines.delay65import kotlinx.coroutines.launch66import javax.inject.Inject6768697071727374757677787980818283848586878889@AndroidEntryPoint90class AutofillEntryPickerActivity : FragmentActivity() {9192 @Inject lateinit var vault: VaultRepository9394 @OptIn(ExperimentalMaterial3Api::class)95 override fun onCreate(savedInstanceState: Bundle?) {96 super.onCreate(savedInstanceState)9798 99 100 101 if (!BuildConfig.DEBUG) {102 window.setFlags(103 WindowManager.LayoutParams.FLAG_SECURE,104 WindowManager.LayoutParams.FLAG_SECURE,105 )106 }107108 val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)109 val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)110 val originTrust = runCatching {111 AutofillOriginTrust.valueOf(112 intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN_TRUST).orEmpty(),113 )114 }.getOrDefault(AutofillOriginTrust.UNKNOWN)115 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)116 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)117 val parsed = ParsedStructure(118 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),119 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),120 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),121 webDomain = origin.takeIf { isWebOrigin },122 packageName = origin.takeUnless { isWebOrigin },123 originTrust = originTrust,124 verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),125 )126 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT127128 setContent {129 PeachTheme(accent = accent) {130 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)131 val scope = rememberCoroutineScope()132 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }133 var error by remember { mutableStateOf<String?>(null) }134 var busy by remember { mutableStateOf(false) }135136 137 138 val allEntries = remember(unlocked) {139 if (unlocked) vault.getEntries() else emptyList()140 }141 val matchedEntries = remember(unlocked, allEntries) {142 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()143 }144145 146 147 148 LaunchedEffect(unlocked) {149 if (!unlocked && canBiometric()) {150 tryBiometric(151 onUnlocked = { unlocked = true },152 onFail = { },153 )154 }155 }156157 ModalBottomSheet(158 onDismissRequest = { cancel() },159 sheetState = sheetState,160 containerColor = peachColors.S1,161 dragHandle = { AccentDragHandle() },162 ) {163 PickerBody(164 origin = parsed.origin,165 originTrust = parsed.originTrust,166 typedIdentity = typedUsername ?: typedEmail,167 unlocked = unlocked,168 matchedEntries = matchedEntries,169 allEntries = allEntries,170 error = error,171 busy = busy,172 canBiometric = canBiometric(),173 onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },174 onUnlock = { password ->175 busy = true176 error = null177 scope.launch {178 when (vault.unlock(password)) {179 is UnlockResult.Success -> unlocked = true180 UnlockResult.WrongPassword -> error = "Wrong master password."181 else -> error = "Couldn't unlock."182 }183 busy = false184 }185 },186 onPick = { entry, confirmedTarget ->187 returnDataset(parsed, entry, confirmedTarget)188 },189 onCreateNew = { confirmedTarget ->190 launchNewEntry(parsed, typedUsername, typedEmail, confirmedTarget)191 },192 )193 }194 }195 }196 }197198 private fun canBiometric(): Boolean =199 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)200201 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {202 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()203 Biometrics.authenticateCrypto(204 activity = this,205 title = "Unlock Peach",206 subtitle = "Fill your saved details",207 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),208 onSuccess = { result ->209 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()210 211 212 213 214 val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()215 lifecycleScope.launch {216 try {217 if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()218 } finally {219 com.peachpasswords.vault.VaultCrypto.zero(key)220 }221 }222 },223 onFail = onFail,224 )225 }226227 private fun returnDataset(228 parsed: ParsedStructure,229 entry: VaultEntry,230 confirmedTarget: String?,231 ) {232 if (233 parsed.originTrust == AutofillOriginTrust.UNKNOWN &&234 AutofillResponseBuilder.matchEntries(listOf(entry), confirmedTarget).hits.isEmpty()235 ) {236 cancel()237 return238 }239 val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)240 if (dataset == null) { cancel(); return }241 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)242 setResult(RESULT_OK, reply)243 finish()244 }245246 247248249250251252 private fun launchNewEntry(253 parsed: ParsedStructure,254 typedUsername: String?,255 typedEmail: String?,256 confirmedTarget: String?,257 ) {258 val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {259 confirmedTarget?.let(::extractDomain) ?: return260 } else {261 parsed.origin262 }263 val intent = Intent(this, MainActivity::class.java).apply {264 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)265 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)266 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)267 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)268 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)269 }270 startActivity(intent)271 cancel()272 }273274 private fun cancel() {275 setResult(RESULT_CANCELED)276 finish()277 }278279 @Suppress("DEPRECATION")280 private fun intentAutofillId(key: String): AutofillId? =281 if (android.os.Build.VERSION.SDK_INT >= 33) {282 intent.getParcelableExtra(key, AutofillId::class.java)283 } else {284 intent.getParcelableExtra(key)285 }286287 private companion object {288 const val MAX_ROWS = 12289 }290}291292internal fun autofillPickerEntries(293 entries: List<VaultEntry>,294 parsed: ParsedStructure,295 limit: Int,296): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)297298299300301302303internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {304 if (query.isEmpty()) return true305 val needle = query.lowercase()306 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)307 .any { it.lowercase().contains(needle) }308}309310@Composable311private fun AccentDragHandle() {312 val accent = LocalPeachAccent.current313 Row(314 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),315 horizontalArrangement = Arrangement.Center,316 ) {317 Box(318 modifier = Modifier319 .size(width = 32.dp, height = 4.dp)320 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),321 )322 }323}324325@Composable326private fun PickerBody(327 origin: String?,328 originTrust: AutofillOriginTrust,329 typedIdentity: String?,330 unlocked: Boolean,331 matchedEntries: List<VaultEntry>,332 allEntries: List<VaultEntry>,333 error: String?,334 busy: Boolean,335 canBiometric: Boolean,336 onBiometric: () -> Unit,337 onUnlock: (String) -> Unit,338 onPick: (VaultEntry, String?) -> Unit,339 onCreateNew: (String?) -> Unit,340) {341 val accent = LocalPeachAccent.current342 val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN343 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {344 Text(345 text = "peach",346 color = accent,347 fontFamily = PeachType.Display,348 fontStyle = FontStyle.Italic,349 fontSize = 15.sp,350 )351 Text(352 text = when {353 originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"354 origin.isNullOrBlank() -> "your vault"355 else -> "Suggested for $origin"356 },357 color = peachColors.T2,358 fontFamily = PeachType.Mono,359 fontSize = 9.sp,360 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),361 )362363 if (originTrust == AutofillOriginTrust.UNKNOWN) {364 Box(365 modifier = Modifier366 .fillMaxWidth()367 .padding(bottom = 14.dp)368 .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))369 .padding(12.dp),370 ) {371 Text(372 text = "Peach couldn't verify this app or site. Enter the site domain yourself; only exact domain matches can be filled.",373 color = peachColors.Breach,374 fontFamily = PeachType.Body,375 fontSize = 12.sp,376 )377 }378 }379380 if (!unlocked) {381 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)382 } else {383 384 385 var rawQuery by remember { mutableStateOf("") }386 var debouncedQuery by remember { mutableStateOf("") }387 LaunchedEffect(rawQuery) {388 delay(SEARCH_DEBOUNCE_MS)389 debouncedQuery = rawQuery390 }391 val confirmedTarget = if (untrustedOrigin) {392 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain)393 } else {394 null395 }396 SearchField(397 value = rawQuery,398 onValueChange = { rawQuery = it },399 placeholder = when {400 untrustedOrigin -> "enter site domain, e.g. example.com"401 matchedEntries.isNotEmpty() -> "search for another entry…"402 else -> "search vault…"403 },404 )405406 val visibleEntries = when {407 untrustedOrigin && confirmedTarget == null -> emptyList()408 untrustedOrigin -> AutofillResponseBuilder409 .matchEntries(allEntries, confirmedTarget)410 .hits411 debouncedQuery.isBlank() -> matchedEntries412 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }413 }414 val showSearchEmpty = debouncedQuery.isNotBlank() &&415 visibleEntries.isEmpty() &&416 (!untrustedOrigin || confirmedTarget != null)417418 val emptyMessage = when {419 untrustedOrigin && debouncedQuery.isBlank() ->420 "Type the site domain to choose a saved login."421 untrustedOrigin && confirmedTarget == null ->422 "Enter a full public domain such as example.com."423 untrustedOrigin && visibleEntries.isEmpty() ->424 "No saved entries match $confirmedTarget."425 matchedEntries.isEmpty() && debouncedQuery.isBlank() ->426 "No saved entries match this site."427 showSearchEmpty ->428 "No entries match \"${debouncedQuery.trim()}\"."429 else -> null430 }431 if (emptyMessage != null) {432 Text(433 text = emptyMessage,434 color = peachColors.T1,435 fontFamily = PeachType.Body,436 fontSize = 13.sp,437 modifier = Modifier.padding(vertical = 6.dp),438 )439 }440441 if (visibleEntries.isNotEmpty()) {442 LazyColumn {443 items(visibleEntries, key = { it.id }) { entry ->444 PickerRow(entry, onClick = { onPick(entry, confirmedTarget) })445 }446 }447 }448449 val createOrigin = if (untrustedOrigin) confirmedTarget else origin450 if (!untrustedOrigin || createOrigin != null) {451 CreateNewRow(452 origin = createOrigin,453 typedIdentity = typedIdentity,454 onClick = { onCreateNew(confirmedTarget) },455 )456 }457 }458 }459}460461@Composable462private fun SearchField(463 value: String,464 onValueChange: (String) -> Unit,465 placeholder: String,466) {467 val accent = LocalPeachAccent.current468 BasicTextField(469 value = value,470 onValueChange = onValueChange,471 singleLine = true,472 textStyle = TextStyle(473 color = peachColors.T0,474 fontFamily = PeachType.Mono,475 fontSize = 14.sp,476 ),477 cursorBrush = SolidColor(accent),478 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),479 keyboardActions = KeyboardActions(onSearch = { }),480 modifier = Modifier481 .fillMaxWidth()482 .background(peachColors.S2, RoundedCornerShape(8.dp))483 .padding(horizontal = 12.dp, vertical = 10.dp),484 decorationBox = { inner ->485 Box(modifier = Modifier.fillMaxWidth()) {486 if (value.isEmpty()) {487 Text(488 text = placeholder,489 color = peachColors.T2,490 fontFamily = PeachType.Mono,491 fontSize = 12.sp,492 )493 }494 inner()495 }496 },497 )498}499500@Composable501private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {502 val accent = LocalPeachAccent.current503 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"504 else "Create new entry"505 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }506 Row(507 modifier = Modifier508 .fillMaxWidth()509 .clickable(onClick = onClick)510 .padding(vertical = 10.dp),511 verticalAlignment = Alignment.CenterVertically,512 ) {513 Box(514 modifier = Modifier515 .size(34.dp)516 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),517 contentAlignment = Alignment.Center,518 ) {519 Text(520 text = "+",521 color = accent,522 fontFamily = PeachType.Display,523 fontStyle = FontStyle.Italic,524 fontSize = 18.sp,525 )526 }527 Column(modifier = Modifier.padding(start = 12.dp)) {528 Text(529 text = title,530 color = accent,531 fontFamily = PeachType.Body,532 fontSize = 14.sp,533 maxLines = 1,534 overflow = TextOverflow.Ellipsis,535 )536 if (subtitle != null) {537 Text(538 text = "prefill: $subtitle",539 color = peachColors.T2,540 fontFamily = PeachType.Mono,541 fontSize = 10.sp,542 maxLines = 1,543 overflow = TextOverflow.Ellipsis,544 )545 }546 }547 }548}549550@Composable551private fun UnlockBody(552 error: String?,553 busy: Boolean,554 canBiometric: Boolean,555 onBiometric: () -> Unit,556 onUnlock: (String) -> Unit,557) {558 val accent = LocalPeachAccent.current559 var password by remember { mutableStateOf("") }560561 Text(562 text = "Unlock to fill",563 color = peachColors.T0,564 fontFamily = PeachType.Body,565 fontSize = 15.sp,566 modifier = Modifier.padding(bottom = 10.dp),567 )568 BasicTextField(569 value = password,570 onValueChange = { password = it },571 singleLine = true,572 enabled = !busy,573 textStyle = TextStyle(574 color = peachColors.T0,575 fontFamily = PeachType.Mono,576 fontSize = 16.sp,577 ),578 cursorBrush = SolidColor(accent),579 visualTransformation = PasswordVisualTransformation(),580 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),581 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),582 modifier = Modifier583 .fillMaxWidth()584 .background(peachColors.S2, RoundedCornerShape(8.dp))585 .padding(horizontal = 12.dp, vertical = 12.dp),586 )587 if (error != null) {588 Text(589 text = error,590 color = peachColors.Breach,591 fontFamily = PeachType.Body,592 fontSize = 12.sp,593 modifier = Modifier.padding(top = 8.dp),594 )595 }596 Row(597 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),598 horizontalArrangement = Arrangement.spacedBy(20.dp),599 verticalAlignment = Alignment.CenterVertically,600 ) {601 if (canBiometric) {602 Text(603 text = "Use biometrics",604 color = accent,605 fontFamily = PeachType.Mono,606 fontSize = 13.sp,607 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),608 )609 }610 Text(611 text = if (busy) "Unlocking…" else "Unlock",612 color = if (password.isEmpty() || busy) peachColors.T2 else accent,613 fontFamily = PeachType.Mono,614 fontSize = 13.sp,615 modifier = Modifier616 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }617 .padding(vertical = 8.dp),618 )619 }620}621622@Composable623private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {624 Row(625 modifier = Modifier626 .fillMaxWidth()627 .clickable(onClick = onClick)628 .padding(vertical = 9.dp),629 verticalAlignment = Alignment.CenterVertically,630 ) {631 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)632 Column(modifier = Modifier.padding(start = 12.dp)) {633 Text(634 text = entry.title,635 color = peachColors.T0,636 fontFamily = PeachType.Body,637 fontSize = 15.sp,638 maxLines = 1,639 overflow = TextOverflow.Ellipsis,640 )641 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""642 if (sub.isNotBlank()) {643 Text(644 text = sub,645 color = peachColors.T2,646 fontFamily = PeachType.Mono,647 fontSize = 11.sp,648 maxLines = 1,649 overflow = TextOverflow.Ellipsis,650 )651 }652 }653 }654}655656657private const val SEARCH_DEBOUNCE_MS = 200L