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 verifiedDomains = intent.getStringArrayListExtra(AutofillIntents.EXTRA_VERIFIED_DOMAINS).orEmpty(),125 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),126 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),127 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),128 cardNumberId = intentAutofillId(AutofillIntents.EXTRA_CARD_NUMBER_ID),129 cardExpMonthId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_MONTH_ID),130 cardExpYearId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_YEAR_ID),131 cardExpDateId = intentAutofillId(AutofillIntents.EXTRA_CARD_EXP_DATE_ID),132 cardCvcId = intentAutofillId(AutofillIntents.EXTRA_CARD_CVC_ID),133 cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),134 )135 val parsed = authenticateAutofillOrigin(this, rawParsed)136 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT137138 setContent {139 PeachTheme(accent = accent) {140 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)141 val scope = rememberCoroutineScope()142 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }143 var unlockedEpoch by remember { mutableStateOf(vault.unlockedSessionEpoch()) }144 var error by remember { mutableStateOf<String?>(null) }145 var busy by remember { mutableStateOf(false) }146147 148 149 val allEntries = remember(unlocked) {150 if (unlocked) vault.getEntries() else emptyList()151 }152 val matchedEntries = remember(unlocked, allEntries) {153 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()154 }155156 157 158 159 LaunchedEffect(unlocked) {160 if (!unlocked && canBiometric()) {161 tryBiometric(162 onUnlocked = {163 unlocked = true164 unlockedEpoch = vault.unlockedSessionEpoch()165 },166 onFail = { },167 )168 }169 }170171 ModalBottomSheet(172 onDismissRequest = { cancel() },173 sheetState = sheetState,174 containerColor = peachColors.S1,175 dragHandle = { AccentDragHandle() },176 ) {177 PickerBody(178 origin = parsed.origin,179 originTrust = parsed.originTrust,180 typedIdentity = typedUsername ?: typedEmail,181 unlocked = unlocked,182 matchedEntries = matchedEntries,183 allEntries = allEntries,184 error = error,185 busy = busy,186 canBiometric = canBiometric(),187 onBiometric = {188 tryBiometric(189 onUnlocked = {190 unlocked = true191 unlockedEpoch = vault.unlockedSessionEpoch()192 },193 onFail = {},194 )195 },196 onUnlock = { password ->197 busy = true198 error = null199 scope.launch {200 when (vault.unlock(password)) {201 is UnlockResult.Success -> {202 unlocked = true203 unlockedEpoch = vault.unlockedSessionEpoch()204 }205 UnlockResult.WrongPassword -> error = "Wrong master password."206 else -> error = "Couldn't unlock."207 }208 busy = false209 }210 },211 onPick = { entry ->212 val epoch = unlockedEpoch213 if (epoch == null || !vault.isSessionEpochCurrent(epoch)) {214 cancel()215 } else {216 returnDataset(parsed, entry)217 }218 },219 onCreateNew = { searchText ->220 launchNewEntry(parsed, typedUsername, typedEmail, searchText)221 },222 )223 }224 }225 }226 }227228 override fun onDestroy() {229 if (debugSecureLease) {230 SecureWindowPolicy.release(window)231 debugSecureLease = false232 }233 super.onDestroy()234 }235236 private fun qaSecureCaptureBypass(): Boolean =237 BuildConfig.DEBUG && (238 intent.getBooleanExtra(QaFlags.EXTRA_DISABLE_SECURE_CAPTURE, false) ||239 QaFlags.disableSecureCaptureForQa240 )241242 private fun canBiometric(): Boolean =243 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)244245 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {246 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()247 Biometrics.authenticateCrypto(248 activity = this,249 title = "Unlock Peach",250 subtitle = "Fill your saved details",251 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),252 onSuccess = { result ->253 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()254 255 256 257 258 lifecycleScope.launch {259 260 261 262 var key: ByteArray? = null263 try {264 key = BiometricKeyStore.unwrap(this@AutofillEntryPickerActivity, used)265 if (key == null) return@launch onFail()266 if (vault.unlockWithKey(key!!) is UnlockResult.Success) onUnlocked() else onFail()267 } finally {268 key?.let(com.peachpasswords.vault.VaultCrypto::zero)269 }270 }271 },272 onFail = onFail,273 )274 }275276 private fun returnDataset(277 parsed: ParsedStructure,278 entry: VaultEntry,279 ) {280 val dataset = if (entry.isCard()) {281 AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null)282 } else {283 AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)284 }285 if (dataset == null) { cancel(); return }286 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)287 setResult(RESULT_OK, reply)288 finish()289 }290291 292293294295296297 private fun launchNewEntry(298 parsed: ParsedStructure,299 typedUsername: String?,300 typedEmail: String?,301 searchText: String?,302 ) {303 val targetOrigin = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {304 searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin305 } else {306 parsed.origin307 }308 val intent = Intent(this, MainActivity::class.java).apply {309 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)310 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)311 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)312 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)313 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)314 }315 startActivity(intent)316 cancel()317 }318319 private fun cancel() {320 setResult(RESULT_CANCELED)321 finish()322 }323324 @Suppress("DEPRECATION")325 private fun intentAutofillId(key: String): AutofillId? =326 if (android.os.Build.VERSION.SDK_INT >= 33) {327 intent.getParcelableExtra(key, AutofillId::class.java)328 } else {329 intent.getParcelableExtra(key)330 }331332 private companion object {333 const val MAX_ROWS = 12334 }335}336337338339340341342343344internal fun pickerParsedFromInputs(345 origin: String?,346 isWebOrigin: Boolean,347 callerPackage: String?,348 verifiedDomains: List<String>,349 usernameId: AutofillId? = null,350 passwordId: AutofillId? = null,351 otpId: AutofillId? = null,352 cardNumberId: AutofillId? = null,353 cardExpMonthId: AutofillId? = null,354 cardExpYearId: AutofillId? = null,355 cardExpDateId: AutofillId? = null,356 cardCvcId: AutofillId? = null,357 cardNameId: AutofillId? = null,358): ParsedStructure = ParsedStructure(359 usernameId = usernameId,360 passwordId = passwordId,361 otpId = otpId,362 cardNumberId = cardNumberId,363 cardExpMonthId = cardExpMonthId,364 cardExpYearId = cardExpYearId,365 cardExpDateId = cardExpDateId,366 cardCvcId = cardCvcId,367 cardNameId = cardNameId,368 webDomain = origin.takeIf { isWebOrigin },369 packageName = if (isWebOrigin) callerPackage else origin,370 371 originTrust = AutofillOriginTrust.UNKNOWN,372 verifiedDomains = verifiedDomains,373)374375internal fun autofillPickerEntries(376 entries: List<VaultEntry>,377 parsed: ParsedStructure,378 limit: Int,379): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)380381382383384385386internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {387 if (query.isEmpty()) return true388 val needle = query.lowercase()389 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)390 .any { it.lowercase().contains(needle) }391}392393@Composable394private fun AccentDragHandle() {395 val accent = LocalPeachAccent.current396 Row(397 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),398 horizontalArrangement = Arrangement.Center,399 ) {400 Box(401 modifier = Modifier402 .size(width = 32.dp, height = 4.dp)403 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),404 )405 }406}407408@Composable409private fun PickerBody(410 origin: String?,411 originTrust: AutofillOriginTrust,412 typedIdentity: String?,413 unlocked: Boolean,414 matchedEntries: List<VaultEntry>,415 allEntries: List<VaultEntry>,416 error: String?,417 busy: Boolean,418 canBiometric: Boolean,419 onBiometric: () -> Unit,420 onUnlock: (String) -> Unit,421 onPick: (VaultEntry) -> Unit,422 onCreateNew: (String?) -> Unit,423) {424 val accent = LocalPeachAccent.current425 val untrustedOrigin = originTrust == AutofillOriginTrust.UNKNOWN426 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {427 Text(428 text = "peach",429 color = accent,430 fontFamily = PeachType.Display,431 fontStyle = FontStyle.Italic,432 fontSize = 15.sp,433 )434 Text(435 text = when {436 originTrust == AutofillOriginTrust.UNKNOWN -> "unverified autofill request"437 origin.isNullOrBlank() -> "your vault"438 else -> "Suggested for $origin"439 },440 color = peachColors.T2,441 fontFamily = PeachType.Mono,442 fontSize = 9.sp,443 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),444 )445446 if (originTrust == AutofillOriginTrust.UNKNOWN) {447 Box(448 modifier = Modifier449 .fillMaxWidth()450 .padding(bottom = 14.dp)451 .background(peachColors.Breach.copy(alpha = 0.12f), RoundedCornerShape(8.dp))452 .padding(12.dp),453 ) {454 Text(455 text = "Peach couldn't verify this app or site. Make sure you trust it before filling a login.",456 color = peachColors.Breach,457 fontFamily = PeachType.Body,458 fontSize = 12.sp,459 )460 }461 }462463 if (!unlocked) {464 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)465 } else {466 467 468 var rawQuery by remember { mutableStateOf("") }469 var debouncedQuery by remember { mutableStateOf("") }470 LaunchedEffect(rawQuery) {471 delay(SEARCH_DEBOUNCE_MS)472 debouncedQuery = rawQuery473 }474 SearchField(475 value = rawQuery,476 onValueChange = { rawQuery = it },477 placeholder = when {478 matchedEntries.isNotEmpty() -> "search for another entry…"479 else -> "search vault…"480 },481 )482483 val visibleEntries = when {484 untrustedOrigin && debouncedQuery.isBlank() -> emptyList()485 debouncedQuery.isBlank() -> matchedEntries486 else -> allEntries.filter { matchesPickerSearch(it, debouncedQuery) }487 }488 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()489490 val emptyMessage = when {491 untrustedOrigin && debouncedQuery.isBlank() ->492 "Search your vault to choose a login."493 untrustedOrigin && visibleEntries.isEmpty() ->494 "No entries match \"${debouncedQuery.trim()}\"."495 matchedEntries.isEmpty() && debouncedQuery.isBlank() ->496 "No saved entries match this site."497 showSearchEmpty ->498 "No entries match \"${debouncedQuery.trim()}\"."499 else -> null500 }501 if (emptyMessage != null) {502 Text(503 text = emptyMessage,504 color = peachColors.T1,505 fontFamily = PeachType.Body,506 fontSize = 13.sp,507 modifier = Modifier.padding(vertical = 6.dp),508 )509 }510511 if (visibleEntries.isNotEmpty()) {512 LazyColumn {513 items(visibleEntries, key = { it.id }) { entry ->514 PickerRow(entry, onClick = { onPick(entry) })515 }516 }517 }518519 val createOrigin = if (untrustedOrigin) {520 debouncedQuery.trim().takeIf { it.isNotBlank() }?.let(::extractDomain) ?: origin521 } else {522 origin523 }524 CreateNewRow(525 origin = createOrigin,526 typedIdentity = typedIdentity,527 onClick = { onCreateNew(debouncedQuery.trim().takeIf { it.isNotBlank() }) },528 )529 }530 }531}532533@Composable534private fun SearchField(535 value: String,536 onValueChange: (String) -> Unit,537 placeholder: String,538) {539 val accent = LocalPeachAccent.current540 BasicTextField(541 value = value,542 onValueChange = onValueChange,543 singleLine = true,544 textStyle = TextStyle(545 color = peachColors.T0,546 fontFamily = PeachType.Mono,547 fontSize = 14.sp,548 ),549 cursorBrush = SolidColor(accent),550 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),551 keyboardActions = KeyboardActions(onSearch = { }),552 modifier = Modifier553 .fillMaxWidth()554 .background(peachColors.S2, RoundedCornerShape(8.dp))555 .padding(horizontal = 12.dp, vertical = 10.dp),556 decorationBox = { inner ->557 Box(modifier = Modifier.fillMaxWidth()) {558 if (value.isEmpty()) {559 Text(560 text = placeholder,561 color = peachColors.T2,562 fontFamily = PeachType.Mono,563 fontSize = 12.sp,564 )565 }566 inner()567 }568 },569 )570}571572@Composable573private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {574 val accent = LocalPeachAccent.current575 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"576 else "Create new entry"577 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }578 Row(579 modifier = Modifier580 .fillMaxWidth()581 .clickable(onClick = onClick)582 .padding(vertical = 10.dp),583 verticalAlignment = Alignment.CenterVertically,584 ) {585 Box(586 modifier = Modifier587 .size(34.dp)588 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),589 contentAlignment = Alignment.Center,590 ) {591 Text(592 text = "+",593 color = accent,594 fontFamily = PeachType.Display,595 fontStyle = FontStyle.Italic,596 fontSize = 18.sp,597 )598 }599 Column(modifier = Modifier.padding(start = 12.dp)) {600 Text(601 text = title,602 color = accent,603 fontFamily = PeachType.Body,604 fontSize = 14.sp,605 maxLines = 1,606 overflow = TextOverflow.Ellipsis,607 )608 if (subtitle != null) {609 Text(610 text = "prefill: $subtitle",611 color = peachColors.T2,612 fontFamily = PeachType.Mono,613 fontSize = 10.sp,614 maxLines = 1,615 overflow = TextOverflow.Ellipsis,616 )617 }618 }619 }620}621622@Composable623private fun UnlockBody(624 error: String?,625 busy: Boolean,626 canBiometric: Boolean,627 onBiometric: () -> Unit,628 onUnlock: (String) -> Unit,629) {630 val accent = LocalPeachAccent.current631 var password by remember { mutableStateOf("") }632633 Text(634 text = "Unlock to fill",635 color = peachColors.T0,636 fontFamily = PeachType.Body,637 fontSize = 15.sp,638 modifier = Modifier.padding(bottom = 10.dp),639 )640 BasicTextField(641 value = password,642 onValueChange = { password = it },643 singleLine = true,644 enabled = !busy,645 textStyle = TextStyle(646 color = peachColors.T0,647 fontFamily = PeachType.Mono,648 fontSize = 16.sp,649 ),650 cursorBrush = SolidColor(accent),651 visualTransformation = PasswordVisualTransformation(),652 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),653 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),654 modifier = Modifier655 .fillMaxWidth()656 .background(peachColors.S2, RoundedCornerShape(8.dp))657 .padding(horizontal = 12.dp, vertical = 12.dp),658 )659 if (error != null) {660 Text(661 text = error,662 color = peachColors.Breach,663 fontFamily = PeachType.Body,664 fontSize = 12.sp,665 modifier = Modifier.padding(top = 8.dp),666 )667 }668 Row(669 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),670 horizontalArrangement = Arrangement.spacedBy(20.dp),671 verticalAlignment = Alignment.CenterVertically,672 ) {673 if (canBiometric) {674 Text(675 text = "Use biometrics",676 color = accent,677 fontFamily = PeachType.Mono,678 fontSize = 13.sp,679 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),680 )681 }682 Text(683 text = if (busy) "Unlocking…" else "Unlock",684 color = if (password.isEmpty() || busy) peachColors.T2 else accent,685 fontFamily = PeachType.Mono,686 fontSize = 13.sp,687 modifier = Modifier688 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }689 .padding(vertical = 8.dp),690 )691 }692}693694@Composable695private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {696 Row(697 modifier = Modifier698 .fillMaxWidth()699 .clickable(onClick = onClick)700 .padding(vertical = 9.dp),701 verticalAlignment = Alignment.CenterVertically,702 ) {703 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)704 Column(modifier = Modifier.padding(start = 12.dp)) {705 Text(706 text = entry.title,707 color = peachColors.T0,708 fontFamily = PeachType.Body,709 fontSize = 15.sp,710 maxLines = 1,711 overflow = TextOverflow.Ellipsis,712 )713 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""714 if (sub.isNotBlank()) {715 Text(716 text = sub,717 color = peachColors.T2,718 fontFamily = PeachType.Mono,719 fontSize = 11.sp,720 maxLines = 1,721 overflow = TextOverflow.Ellipsis,722 )723 }724 }725 }726}727728729private const val SEARCH_DEBOUNCE_MS = 200L