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 147 148 val allEntries = remember(unlocked) {149 if (unlocked) vault.getEntries() else emptyList()150 }151 val matchedEntries = remember(unlocked, allEntries) {152 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()153 }154155 156 157 158 LaunchedEffect(unlocked) {159 if (!unlocked && canBiometric()) {160 tryBiometric(161 onUnlocked = {162 unlocked = true163 unlockedEpoch = vault.unlockedSessionEpoch()164 },165 onFail = { },166 )167 }168 }169170 ModalBottomSheet(171 onDismissRequest = { cancel() },172 sheetState = sheetState,173 containerColor = peachColors.S1,174 dragHandle = { AccentDragHandle() },175 ) {176 PickerBody(177 origin = parsed.origin,178 typedIdentity = typedUsername ?: typedEmail,179 unlocked = unlocked,180 matchedEntries = matchedEntries,181 allEntries = allEntries,182 error = error,183 busy = busy,184 canBiometric = canBiometric(),185 onBiometric = {186 tryBiometric(187 onUnlocked = {188 unlocked = true189 unlockedEpoch = vault.unlockedSessionEpoch()190 },191 onFail = {},192 )193 },194 onUnlock = { password ->195 busy = true196 error = null197 scope.launch {198 when (vault.unlock(password)) {199 is UnlockResult.Success -> {200 unlocked = true201 unlockedEpoch = vault.unlockedSessionEpoch()202 }203 UnlockResult.WrongPassword -> error = "Wrong master password."204 else -> error = "Couldn't unlock."205 }206 busy = false207 }208 },209 onPick = { entry ->210 val epoch = unlockedEpoch211 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {212 cancel()213 } else {214 returnDataset(parsed, entry)215 }216 },217 onCreateNew = { searchText ->218 launchNewEntry(parsed, typedUsername, typedEmail, searchText)219 },220 )221 }222 }223 }224 }225226 override fun onDestroy() {227 if (debugSecureLease) {228 SecureWindowPolicy.release(window)229 debugSecureLease = false230 }231 super.onDestroy()232 }233234 private fun qaSecureCaptureBypass(): Boolean =235 BuildConfig.DEBUG && (236 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||237 QaFlags.disableSecureCaptureForQa238 )239240 private fun canBiometric(): Boolean =241 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)242243 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {244 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()245 Biometrics.authenticateCrypto(246 activity = this,247 title = "Unlock Peach",248 subtitle = "Fill your saved details",249 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),250 onSuccess = { result ->251 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()252 253 254 255 256 lifecycleScope.launch {257 258 259 260 var key: ByteArray? = null261 try {262 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)263 if (key == null) return@launch onFail()264 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()265 } finally {266 key?.let(com.peachpasswords.vault.VaultCrypto::zero)267 }268 }269 },270 onFail = onFail,271 )272 }273274 private fun returnDataset(275 parsed: ParsedStructure,276 entry: VaultEntry,277 ) {278 val dataset = if (entry.isCard()) {279 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)280 } else {281 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)282 }283 if (dataset == null) { cancel(); return }284 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)285 setResult(RESULT_OK, reply)286 finish()287 }288289 290291292293294295 private fun launchNewEntry(296 parsed: ParsedStructure,297 typedUsername: String?,298 typedEmail: String?,299 searchText: String?,300 ) {301 val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin302 val intent = Intent(this, MainActivity::class.java).apply {303 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)304 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)305 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)306 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)307 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)308 }309 startActivity(intent)310 cancel()311 }312313 private fun cancel() {314 setResult(RESULT_CANCELED)315 finish()316 }317318 @Suppress("DEPRECATION")319 private fun intentAutofillId(key: String): AutofillId? =320 if (android.os.Build.VERSION.SDK_INT >= 33) {321 intent.getParcelableExtra(key, AutofillId::class.java)322 } else {323 intent.getParcelableExtra(key)324 }325326 private companion object {327 const val MAX_ROWS = 12328 }329}330331332333334335336internal fun pickerParsedFromInputs(337 origin: String?,338 isWebOrigin: Boolean,339 callerPackage: String?,340 usernameId: AutofillId? = null,341 passwordId: AutofillId? = null,342 otpId: AutofillId? = null,343 cardNumberId: AutofillId? = null,344 cardExpMonthId: AutofillId? = null,345 cardExpYearId: AutofillId? = null,346 cardExpDateId: AutofillId? = null,347 cardCvcId: AutofillId? = null,348 cardNameId: AutofillId? = null,349): ParsedStructure = ParsedStructure(350 usernameId = usernameId,351 passwordId = passwordId,352 otpId = otpId,353 cardNumberId = cardNumberId,354 cardExpMonthId = cardExpMonthId,355 cardExpYearId = cardExpYearId,356 cardExpDateId = cardExpDateId,357 cardCvcId = cardCvcId,358 cardNameId = cardNameId,359 webDomain = origin.takeIf { isWebOrigin },360 packageName = if (isWebOrigin) callerPackage else origin,361)362363internal fun autofillPickerEntries(364 entries: List<VaultEntry>,365 parsed: ParsedStructure,366 limit: Int,367): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)368369370371372373374internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {375 if (query.isEmpty()) return true376 val needle = query.lowercase()377 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)378 .any { it.lowercase().contains(needle) }379}380381@Composable382private fun AccentDragHandle() {383 val accent = LocalPeachAccent.current384 Row(385 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),386 horizontalArrangement = Arrangement.Center,387 ) {388 Box(389 modifier = Modifier390 .size(width = 32.dp, height = 4.dp)391 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),392 )393 }394}395396@Composable397private fun PickerBody(398 origin: String?,399 typedIdentity: String?,400 unlocked: Boolean,401 matchedEntries: List<VaultEntry>,402 allEntries: List<VaultEntry>,403 error: String?,404 busy: Boolean,405 canBiometric: Boolean,406 onBiometric: () -> Unit,407 onUnlock: (String) -> Unit,408 onPick: (VaultEntry) -> Unit,409 onCreateNew: (String?) -> Unit,410) {411 val accent = LocalPeachAccent.current412 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {413 Text(414 text = "peach",415 color = accent,416 fontFamily = PeachType.Display,417 fontStyle = FontStyle.Italic,418 fontSize = 15.sp,419 )420 Text(421 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",422 color = peachColors.T2,423 fontFamily = PeachType.Mono,424 fontSize = 9.sp,425 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),426 )427428 if (!unlocked) {429 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)430 } else {431 432 433 var rawQuery by remember { mutableStateOf("") }434 var debouncedQuery by remember { mutableStateOf("") }435 LaunchedEffect(rawQuery) {436 delay(SEARCH_DEBOUNCE_MS)437 debouncedQuery = rawQuery438 }439 SearchField(440 value = rawQuery,441 onValueChange = { rawQuery = it },442 placeholder = when {443 matchedEntries.isNotEmpty() -> "search for another entry…"444 else -> "search vault…"445 },446 )447448 val visibleEntries = when {449 debouncedQuery.isBlank() -> matchedEntries450 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }451 }452 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()453454 val emptyMessage = when {455 matchedEntries.isEmpty() && debouncedQuery.isBlank() ->456 "No saved entries match this site."457 showSearchEmpty ->458 "No entries match \"${debouncedQuery.trim()}\"."459 else -> null460 }461 if (emptyMessage != null) {462 Text(463 text = emptyMessage,464 color = peachColors.T1,465 fontFamily = PeachType.Body,466 fontSize = 13.sp,467 modifier = Modifier.padding(vertical = 6.dp),468 )469 }470471 if (visibleEntries.isNotEmpty()) {472 LazyColumn {473 items(visibleEntries, key = { it.id }) { entry ->474 PickerRow(entry, onClick = { onPick(entry) })475 }476 }477 }478479 val createOrigin =480 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin481 CreateNewRow(482 origin = createOrigin,483 typedIdentity = typedIdentity,484 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },485 )486 }487 }488}489490@Composable491private fun SearchField(492 value: String,493 onValueChange: (String) -> Unit,494 placeholder: String,495) {496 val accent = LocalPeachAccent.current497 BasicTextField(498 value = value,499 onValueChange = onValueChange,500 singleLine = true,501 textStyle = TextStyle(502 color = peachColors.T0,503 fontFamily = PeachType.Mono,504 fontSize = 14.sp,505 ),506 cursorBrush = SolidColor(accent),507 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),508 keyboardActions = KeyboardActions(onSearch = { }),509 modifier = Modifier510 .fillMaxWidth()511 .background(peachColors.S2, RoundedCornerShape(8.dp))512 .padding(horizontal = 12.dp, vertical = 10.dp),513 decorationBox = { inner ->514 Box(modifier = Modifier.fillMaxWidth()) {515 if (value.isEmpty()) {516 Text(517 text = placeholder,518 color = peachColors.T2,519 fontFamily = PeachType.Mono,520 fontSize = 12.sp,521 )522 }523 inner()524 }525 },526 )527}528529@Composable530private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {531 val accent = LocalPeachAccent.current532 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"533 else "Create new entry"534 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }535 Row(536 modifier = Modifier537 .fillMaxWidth()538 .clickable(onClick = onClick)539 .padding(vertical = 10.dp),540 verticalAlignment = Alignment.CenterVertically,541 ) {542 Box(543 modifier = Modifier544 .size(34.dp)545 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),546 contentAlignment = Alignment.Center,547 ) {548 Text(549 text = "+",550 color = accent,551 fontFamily = PeachType.Display,552 fontStyle = FontStyle.Italic,553 fontSize = 18.sp,554 )555 }556 Column(modifier = Modifier.padding(start = 12.dp)) {557 Text(558 text = title,559 color = accent,560 fontFamily = PeachType.Body,561 fontSize = 14.sp,562 maxLines = 1,563 overflow = TextOverflow.Ellipsis,564 )565 if (subtitle != null) {566 Text(567 text = "prefill: $subtitle",568 color = peachColors.T2,569 fontFamily = PeachType.Mono,570 fontSize = 10.sp,571 maxLines = 1,572 overflow = TextOverflow.Ellipsis,573 )574 }575 }576 }577}578579@Composable580private fun UnlockBody(581 error: String?,582 busy: Boolean,583 canBiometric: Boolean,584 onBiometric: () -> Unit,585 onUnlock: (String) -> Unit,586) {587 val accent = LocalPeachAccent.current588 var password by remember { mutableStateOf("") }589590 Text(591 text = "Unlock to fill",592 color = peachColors.T0,593 fontFamily = PeachType.Body,594 fontSize = 15.sp,595 modifier = Modifier.padding(bottom = 10.dp),596 )597 BasicTextField(598 value = password,599 onValueChange = { password = it },600 singleLine = true,601 enabled = !busy,602 textStyle = TextStyle(603 color = peachColors.T0,604 fontFamily = PeachType.Mono,605 fontSize = 16.sp,606 ),607 cursorBrush = SolidColor(accent),608 visualTransformation = PasswordVisualTransformation(),609 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),610 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),611 modifier = Modifier612 .fillMaxWidth()613 .background(peachColors.S2, RoundedCornerShape(8.dp))614 .padding(horizontal = 12.dp, vertical = 12.dp),615 )616 if (error != null) {617 Text(618 text = error,619 color = peachColors.Breach,620 fontFamily = PeachType.Body,621 fontSize = 12.sp,622 modifier = Modifier.padding(top = 8.dp),623 )624 }625 Row(626 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),627 horizontalArrangement = Arrangement.spacedBy(20.dp),628 verticalAlignment = Alignment.CenterVertically,629 ) {630 if (canBiometric) {631 Text(632 text = "Use biometrics",633 color = accent,634 fontFamily = PeachType.Mono,635 fontSize = 13.sp,636 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),637 )638 }639 Text(640 text = if (busy) "Unlocking…" else "Unlock",641 color = if (password.isEmpty() || busy) peachColors.T2 else accent,642 fontFamily = PeachType.Mono,643 fontSize = 13.sp,644 modifier = Modifier645 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }646 .padding(vertical = 8.dp),647 )648 }649}650651@Composable652private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {653 Row(654 modifier = Modifier655 .fillMaxWidth()656 .clickable(onClick = onClick)657 .padding(vertical = 9.dp),658 verticalAlignment = Alignment.CenterVertically,659 ) {660 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)661 Column(modifier = Modifier.padding(start = 12.dp)) {662 Text(663 text = entry.title,664 color = peachColors.T0,665 fontFamily = PeachType.Body,666 fontSize = 15.sp,667 maxLines = 1,668 overflow = TextOverflow.Ellipsis,669 )670 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""671 if (sub.isNotBlank()) {672 Text(673 text = sub,674 color = peachColors.T2,675 fontFamily = PeachType.Mono,676 fontSize = 11.sp,677 maxLines = 1,678 overflow = TextOverflow.Ellipsis,679 )680 }681 }682 }683}684685686private const val SEARCH_DEBOUNCE_MS = 200L