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.VaultRepository64import dagger.hilt.android.AndroidEntryPoint65import kotlinx.coroutines.delay66import kotlinx.coroutines.launch67import javax.inject.Inject6869707172737475767778798081828384858687888990@AndroidEntryPoint91class AutofillEntryPickerActivity : FragmentActivity() {9293 @Inject lateinit var vault: VaultRepository94 private var debugSecureLease = false9596 @OptIn(ExperimentalMaterial3Api::class)97 override fun onCreate(savedInstanceState: Bundle?) {98 super.onCreate(savedInstanceState)99100 101 102 103 if (!BuildConfig.DEBUG) {104 SecureWindowPolicy.installRelease(window)105 } else if (!qaSecureCaptureBypass()) {106 107 108 109 SecureWindowPolicy.acquire(window)110 debugSecureLease = true111 }112113 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)114 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)115 116 117 118 119 val rawParsed = pickerParsedFromInputs(120 origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN),121 isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false),122 callerPackage = intent.getStringExtra(AutofillIntents.EXTRA_CALLER_PACKAGE),123 verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),124 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),125 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),126 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),127 )128 val parsed = authenticateAutofillOrigin(this, rawParsed)129 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT130131 setContent {132 PeachTheme(accent = accent) {133 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)134 val scope = rememberCoroutineScope()135 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }136 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }137 var error by remember { mutableStateOf<String?>(null) }138 var busy by remember { mutableStateOf(false) }139140 141 142 val allEntries = remember(unlocked) {143 if (unlocked) vault.getEntries() else emptyList()144 }145 val matchedEntries = remember(unlocked, allEntries) {146 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()147 }148149 150 151 152 LaunchedEffect(unlocked) {153 if (!unlocked && canBiometric()) {154 tryBiometric(155 onUnlocked = {156 unlocked = true157 unlockedEpoch = vault.unlockedSessionEpoch()158 },159 onFail = { },160 )161 }162 }163164 ModalBottomSheet(165 onDismissRequest = { cancel() },166 sheetState = sheetState,167 containerColor = peachColors.S1,168 dragHandle = { AccentDragHandle() },169 ) {170 PickerBody(171 origin = parsed.origin,172 originTrust = parsed.originTrust,173 typedIdentity = typedUsername ?: typedEmail,174 unlocked = unlocked,175 matchedEntries = matchedEntries,176 allEntries = allEntries,177 error = error,178 busy = busy,179 canBiometric = canBiometric(),180 onBiometric = {181 tryBiometric(182 onUnlocked = {183 unlocked = true184 unlockedEpoch = vault.unlockedSessionEpoch()185 },186 onFail = {},187 )188 },189 onUnlock = { password ->190 busy = true191 error = null192 scope.launch {193 when (vault.unlock(password)) {194 is UnlockResult.Success -> {195 unlocked = true196 unlockedEpoch = vault.unlockedSessionEpoch()197 }198 UnlockResult.WrongPassword -> error = "Wrong master password."199 else -> error = "Couldn't unlock."200 }201 busy = false202 }203 },204 onPick = { entry ->205 val epoch = unlockedEpoch206 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {207 cancel()208 } else {209 returnDataset(parsed, entry)210 }211 },212 onCreateNew = { searchText ->213 launchNewEntry(parsed, typedUsername, typedEmail, searchText)214 },215 )216 }217 }218 }219 }220221 override fun onDestroy() {222 if (debugSecureLease) {223 SecureWindowPolicy.release(window)224 debugSecureLease = false225 }226 super.onDestroy()227 }228229 private fun qaSecureCaptureBypass(): Boolean =230 BuildConfig.DEBUG && (231 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||232 QaFlags.disableSecureCaptureForQa233 )234235 private fun canBiometric(): Boolean =236 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)237238 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {239 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()240 Biometrics.authenticateCrypto(241 activity = this,242 title = "Unlock Peach",243 subtitle = "Fill your saved details",244 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),245 onSuccess = { result ->246 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()247 248 249 250 251 lifecycleScope.launch {252 253 254 255 var key: ByteArray? = null256 try {257 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)258 if (key == null) return@launch onFail()259 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()260 } finally {261 key?.let(com.peachpasswords.vault.VaultCrypto::zero)262 }263 }264 },265 onFail = onFail,266 )267 }268269 private fun returnDataset(270 parsed: ParsedStructure,271 entry: VaultEntry,272 ) {273 val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)274 if (dataset == null) { cancel(); return }275 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)276 setResult(RESULT_OK, reply)277 finish()278 }279280 281282283284285286 private fun launchNewEntry(287 parsed: ParsedStructure,288 typedUsername: String?,289 typedEmail: String?,290 searchText: String?,291 ) {292 val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {293 searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin294 } else {295 parsed.origin296 }297 val intent = Intent(this, MainActivity::class.java).apply {298 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)299 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)300 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)301 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)302 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)303 }304 startActivity(intent)305 cancel()306 }307308 private fun cancel() {309 setResult(RESULT_CANCELED)310 finish()311 }312313 @Suppress("DEPRECATION")314 private fun intentAutofillId(key: String): AutofillId? =315 if (android.os.Build.VERSION.SDK_INT >= 33) {316 intent.getParcelableExtra(key, AutofillId::class.java)317 } else {318 intent.getParcelableExtra(key)319 }320321 private companion object {322 const val MAX_ROWS = 12323 }324}325326327328329330331332333internal fun pickerParsedFromInputs(334 origin: String?,335 isWebOrigin: Boolean,336 callerPackage: String?,337 verifiedDomains: List<String>,338 usernameId: AutofillId? = null,339 passwordId: AutofillId? = null,340 otpId: AutofillId? = null,341): ParsedStructure = ParsedStructure(342 usernameId = usernameId,343 passwordId = passwordId,344 otpId = otpId,345 webDomain = origin.takeIf { isWebOrigin },346 packageName = if (isWebOrigin) callerPackage else origin,347 348 originTrust = AutofillOriginTrust.UNKNOWN,349 verifiedDomains = verifiedDomains,350)351352internal fun autofillPickerEntries(353 entries: List<VaultEntry>,354 parsed: ParsedStructure,355 limit: Int,356): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)357358359360361362363internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {364 if (query.isEmpty()) return true365 val needle = query.lowercase()366 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)367 .any { it.lowercase().contains(needle) }368}369370@Composable371private fun AccentDragHandle() {372 val accent = LocalPeachAccent.current373 Row(374 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),375 horizontalArrangement = Arrangement.Center,376 ) {377 Box(378 modifier = Modifier379 .size(width = 32.dp, height = 4.dp)380 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),381 )382 }383}384385@Composable386private fun PickerBody(387 origin: String?,388 originTrust: AutofillOriginTrust,389 typedIdentity: String?,390 unlocked: Boolean,391 matchedEntries: List<VaultEntry>,392 allEntries: List<VaultEntry>,393 error: String?,394 busy: Boolean,395 canBiometric: Boolean,396 onBiometric: () -> Unit,397 onUnlock: (String) -> Unit,398 onPick: (VaultEntry) -> Unit,399 onCreateNew: (String?) -> Unit,400) {401 val accent = LocalPeachAccent.current402 val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN403 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {404 Text(405 text = "peach",406 color = accent,407 fontFamily = PeachType.Display,408 fontStyle = FontStyle.Italic,409 fontSize = 15.sp,410 )411 Text(412 text = when {413 originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"414 origin.isNullOrBlank() -> "your vault"415 else -> "Suggested for $origin"416 },417 color = peachColors.T2,418 fontFamily = PeachType.Mono,419 fontSize = 9.sp,420 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),421 )422423 if (originTrust == AutofillOriginTrust.UNKNOWN) {424 Box(425 modifier = Modifier426 .fillMaxWidth()427 .padding(bottom = 14.dp)428 .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))429 .padding(12.dp),430 ) {431 Text(432 text = "Peach couldn't verify this app or site. Make sure you trust it before filling a login.",433 color = peachColors.Breach,434 fontFamily = PeachType.Body,435 fontSize = 12.sp,436 )437 }438 }439440 if (!unlocked) {441 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)442 } else {443 444 445 var rawQuery by remember { mutableStateOf("") }446 var debouncedQuery by remember { mutableStateOf("") }447 LaunchedEffect(rawQuery) {448 delay(SEARCH_DEBOUNCE_MS)449 debouncedQuery = rawQuery450 }451 SearchField(452 value = rawQuery,453 onValueChange = { rawQuery = it },454 placeholder = when {455 matchedEntries.isNotEmpty() -> "search for another entry…"456 else -> "search vault…"457 },458 )459460 val visibleEntries = when {461 untrustedOrigin && debouncedQuery.isBlank() -> emptyList()462 debouncedQuery.isBlank() -> matchedEntries463 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }464 }465 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()466467 val emptyMessage = when {468 untrustedOrigin && debouncedQuery.isBlank() ->469 "Search your vault to choose a login."470 untrustedOrigin && visibleEntries.isEmpty() ->471 "No entries match \"${debouncedQuery.trim()}\"."472 matchedEntries.isEmpty() && debouncedQuery.isBlank() ->473 "No saved entries match this site."474 showSearchEmpty ->475 "No entries match \"${debouncedQuery.trim()}\"."476 else -> null477 }478 if (emptyMessage != null) {479 Text(480 text = emptyMessage,481 color = peachColors.T1,482 fontFamily = PeachType.Body,483 fontSize = 13.sp,484 modifier = Modifier.padding(vertical = 6.dp),485 )486 }487488 if (visibleEntries.isNotEmpty()) {489 LazyColumn {490 items(visibleEntries, key = { it.id }) { entry ->491 PickerRow(entry, onClick = { onPick(entry) })492 }493 }494 }495496 val createOrigin = if (untrustedOrigin) {497 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin498 } else {499 origin500 }501 CreateNewRow(502 origin = createOrigin,503 typedIdentity = typedIdentity,504 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },505 )506 }507 }508}509510@Composable511private fun SearchField(512 value: String,513 onValueChange: (String) -> Unit,514 placeholder: String,515) {516 val accent = LocalPeachAccent.current517 BasicTextField(518 value = value,519 onValueChange = onValueChange,520 singleLine = true,521 textStyle = TextStyle(522 color = peachColors.T0,523 fontFamily = PeachType.Mono,524 fontSize = 14.sp,525 ),526 cursorBrush = SolidColor(accent),527 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),528 keyboardActions = KeyboardActions(onSearch = { }),529 modifier = Modifier530 .fillMaxWidth()531 .background(peachColors.S2, RoundedCornerShape(8.dp))532 .padding(horizontal = 12.dp, vertical = 10.dp),533 decorationBox = { inner ->534 Box(modifier = Modifier.fillMaxWidth()) {535 if (value.isEmpty()) {536 Text(537 text = placeholder,538 color = peachColors.T2,539 fontFamily = PeachType.Mono,540 fontSize = 12.sp,541 )542 }543 inner()544 }545 },546 )547}548549@Composable550private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {551 val accent = LocalPeachAccent.current552 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"553 else "Create new entry"554 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }555 Row(556 modifier = Modifier557 .fillMaxWidth()558 .clickable(onClick = onClick)559 .padding(vertical = 10.dp),560 verticalAlignment = Alignment.CenterVertically,561 ) {562 Box(563 modifier = Modifier564 .size(34.dp)565 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),566 contentAlignment = Alignment.Center,567 ) {568 Text(569 text = "+",570 color = accent,571 fontFamily = PeachType.Display,572 fontStyle = FontStyle.Italic,573 fontSize = 18.sp,574 )575 }576 Column(modifier = Modifier.padding(start = 12.dp)) {577 Text(578 text = title,579 color = accent,580 fontFamily = PeachType.Body,581 fontSize = 14.sp,582 maxLines = 1,583 overflow = TextOverflow.Ellipsis,584 )585 if (subtitle != null) {586 Text(587 text = "prefill: $subtitle",588 color = peachColors.T2,589 fontFamily = PeachType.Mono,590 fontSize = 10.sp,591 maxLines = 1,592 overflow = TextOverflow.Ellipsis,593 )594 }595 }596 }597}598599@Composable600private fun UnlockBody(601 error: String?,602 busy: Boolean,603 canBiometric: Boolean,604 onBiometric: () -> Unit,605 onUnlock: (String) -> Unit,606) {607 val accent = LocalPeachAccent.current608 var password by remember { mutableStateOf("") }609610 Text(611 text = "Unlock to fill",612 color = peachColors.T0,613 fontFamily = PeachType.Body,614 fontSize = 15.sp,615 modifier = Modifier.padding(bottom = 10.dp),616 )617 BasicTextField(618 value = password,619 onValueChange = { password = it },620 singleLine = true,621 enabled = !busy,622 textStyle = TextStyle(623 color = peachColors.T0,624 fontFamily = PeachType.Mono,625 fontSize = 16.sp,626 ),627 cursorBrush = SolidColor(accent),628 visualTransformation = PasswordVisualTransformation(),629 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),630 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),631 modifier = Modifier632 .fillMaxWidth()633 .background(peachColors.S2, RoundedCornerShape(8.dp))634 .padding(horizontal = 12.dp, vertical = 12.dp),635 )636 if (error != null) {637 Text(638 text = error,639 color = peachColors.Breach,640 fontFamily = PeachType.Body,641 fontSize = 12.sp,642 modifier = Modifier.padding(top = 8.dp),643 )644 }645 Row(646 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),647 horizontalArrangement = Arrangement.spacedBy(20.dp),648 verticalAlignment = Alignment.CenterVertically,649 ) {650 if (canBiometric) {651 Text(652 text = "Use biometrics",653 color = accent,654 fontFamily = PeachType.Mono,655 fontSize = 13.sp,656 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),657 )658 }659 Text(660 text = if (busy) "Unlocking…" else "Unlock",661 color = if (password.isEmpty() || busy) peachColors.T2 else accent,662 fontFamily = PeachType.Mono,663 fontSize = 13.sp,664 modifier = Modifier665 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }666 .padding(vertical = 8.dp),667 )668 }669}670671@Composable672private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {673 Row(674 modifier = Modifier675 .fillMaxWidth()676 .clickable(onClick = onClick)677 .padding(vertical = 9.dp),678 verticalAlignment = Alignment.CenterVertically,679 ) {680 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)681 Column(modifier = Modifier.padding(start = 12.dp)) {682 Text(683 text = entry.title,684 color = peachColors.T0,685 fontFamily = PeachType.Body,686 fontSize = 15.sp,687 maxLines = 1,688 overflow = TextOverflow.Ellipsis,689 )690 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""691 if (sub.isNotBlank()) {692 Text(693 text = sub,694 color = peachColors.T2,695 fontFamily = PeachType.Mono,696 fontSize = 11.sp,697 maxLines = 1,698 overflow = TextOverflow.Ellipsis,699 )700 }701 }702 }703}704705706private const val SEARCH_DEBOUNCE_MS = 200L