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 typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)111 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)112 val parsed = ParsedStructure(113 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),114 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),115 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),116 webDomain = origin.takeIf { isWebOrigin },117 packageName = origin.takeUnless { isWebOrigin },118 )119 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT120121 setContent {122 PeachTheme(accent = accent) {123 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)124 val scope = rememberCoroutineScope()125 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }126 var error by remember { mutableStateOf<String?>(null) }127 var busy by remember { mutableStateOf(false) }128129 130 131 132 133 val allEntries = remember(unlocked) {134 if (unlocked) vault.getEntries() else emptyList()135 }136 val matchedEntries = remember(unlocked, allEntries) {137 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()138 }139140 141 142 143 LaunchedEffect(unlocked) {144 if (!unlocked && canBiometric()) {145 tryBiometric(146 onUnlocked = { unlocked = true },147 onFail = { },148 )149 }150 }151152 ModalBottomSheet(153 onDismissRequest = { cancel() },154 sheetState = sheetState,155 containerColor = peachColors.S1,156 dragHandle = { AccentDragHandle() },157 ) {158 PickerBody(159 origin = parsed.origin,160 typedIdentity = typedUsername ?: typedEmail,161 unlocked = unlocked,162 matchedEntries = matchedEntries,163 allEntries = allEntries,164 error = error,165 busy = busy,166 canBiometric = canBiometric(),167 onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },168 onUnlock = { password ->169 busy = true170 error = null171 scope.launch {172 when (vault.unlock(password)) {173 is UnlockResult.Success -> unlocked = true174 UnlockResult.WrongPassword -> error = "Wrong master password."175 else -> error = "Couldn't unlock."176 }177 busy = false178 }179 },180 onPick = { returnDataset(parsed, it) },181 onCreateNew = { launchNewEntry(parsed, typedUsername, typedEmail) },182 )183 }184 }185 }186 }187188 private fun canBiometric(): Boolean =189 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)190191 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {192 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()193 Biometrics.authenticateCrypto(194 activity = this,195 title = "Unlock Peach",196 subtitle = "Fill your saved details",197 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),198 onSuccess = { result ->199 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()200 201 202 203 204 val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()205 lifecycleScope.launch {206 try {207 if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()208 } finally {209 com.peachpasswords.vault.VaultCrypto.zero(key)210 }211 }212 },213 onFail = onFail,214 )215 }216217 private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {218 val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)219 if (dataset == null) { cancel(); return }220 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)221 setResult(RESULT_OK, reply)222 finish()223 }224225 226227228229230231 private fun launchNewEntry(parsed: ParsedStructure, typedUsername: String?, typedEmail: String?) {232 val typedIdentity = typedUsername ?: typedEmail233 val intent = Intent(this, MainActivity::class.java).apply {234 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)235 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, parsed.origin)236 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)237 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)238 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)239 }240 startActivity(intent)241 cancel()242 }243244 private fun cancel() {245 setResult(RESULT_CANCELED)246 finish()247 }248249 @Suppress("DEPRECATION")250 private fun intentAutofillId(key: String): AutofillId? =251 if (android.os.Build.VERSION.SDK_INT >= 33) {252 intent.getParcelableExtra(key, AutofillId::class.java)253 } else {254 intent.getParcelableExtra(key)255 }256257 private companion object {258 const val MAX_ROWS = 12259 }260}261262internal fun autofillPickerEntries(263 entries: List<VaultEntry>,264 parsed: ParsedStructure,265 limit: Int,266): List<VaultEntry> = when {267 parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit)268 parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit)269 else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)270}271272273274275276277internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {278 if (query.isEmpty()) return true279 val needle = query.lowercase()280 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)281 .any { it.lowercase().contains(needle) }282}283284@Composable285private fun AccentDragHandle() {286 val accent = LocalPeachAccent.current287 Row(288 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),289 horizontalArrangement = Arrangement.Center,290 ) {291 Box(292 modifier = Modifier293 .size(width = 32.dp, height = 4.dp)294 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),295 )296 }297}298299@Composable300private fun PickerBody(301 origin: String?,302 typedIdentity: String?,303 unlocked: Boolean,304 matchedEntries: List<VaultEntry>,305 allEntries: List<VaultEntry>,306 error: String?,307 busy: Boolean,308 canBiometric: Boolean,309 onBiometric: () -> Unit,310 onUnlock: (String) -> Unit,311 onPick: (VaultEntry) -> Unit,312 onCreateNew: () -> Unit,313) {314 val accent = LocalPeachAccent.current315 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {316 Text(317 text = "peach",318 color = accent,319 fontFamily = PeachType.Display,320 fontStyle = FontStyle.Italic,321 fontSize = 15.sp,322 )323 Text(324 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",325 color = peachColors.T2,326 fontFamily = PeachType.Mono,327 fontSize = 9.sp,328 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),329 )330331 if (!unlocked) {332 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)333 } else {334 335 336 337 var rawQuery by remember { mutableStateOf("") }338 var debouncedQuery by remember { mutableStateOf("") }339 LaunchedEffect(rawQuery) {340 delay(SEARCH_DEBOUNCE_MS)341 debouncedQuery = rawQuery342 }343 SearchField(344 value = rawQuery,345 onValueChange = { rawQuery = it },346 placeholder = if (matchedEntries.isNotEmpty())347 "search for another entry…" else "search vault…",348 )349350 351 352 353 354 355 356 val visibleEntries = if (debouncedQuery.isBlank()) {357 matchedEntries358 } else {359 allEntries.filter { matchesPickerSearch(it, debouncedQuery) }360 }361 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()362363 if (matchedEntries.isEmpty() && debouncedQuery.isBlank()) {364 Text(365 text = "No saved entries match this site.",366 color = peachColors.T1,367 fontFamily = PeachType.Body,368 fontSize = 13.sp,369 modifier = Modifier.padding(vertical = 6.dp),370 )371 } else if (showSearchEmpty) {372 Text(373 text = "No entries match \"${debouncedQuery.trim()}\".",374 color = peachColors.T1,375 fontFamily = PeachType.Body,376 fontSize = 13.sp,377 modifier = Modifier.padding(vertical = 6.dp),378 )379 }380381 if (visibleEntries.isNotEmpty()) {382 LazyColumn {383 items(visibleEntries, key = { it.id }) { entry ->384 PickerRow(entry, onClick = { onPick(entry) })385 }386 }387 }388389 390 391 392 CreateNewRow(origin = origin, typedIdentity = typedIdentity, onClick = onCreateNew)393 }394 }395}396397@Composable398private fun SearchField(399 value: String,400 onValueChange: (String) -> Unit,401 placeholder: String,402) {403 val accent = LocalPeachAccent.current404 BasicTextField(405 value = value,406 onValueChange = onValueChange,407 singleLine = true,408 textStyle = TextStyle(409 color = peachColors.T0,410 fontFamily = PeachType.Mono,411 fontSize = 14.sp,412 ),413 cursorBrush = SolidColor(accent),414 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),415 keyboardActions = KeyboardActions(onSearch = { }),416 modifier = Modifier417 .fillMaxWidth()418 .background(peachColors.S2, RoundedCornerShape(8.dp))419 .padding(horizontal = 12.dp, vertical = 10.dp),420 decorationBox = { inner ->421 Box(modifier = Modifier.fillMaxWidth()) {422 if (value.isEmpty()) {423 Text(424 text = placeholder,425 color = peachColors.T2,426 fontFamily = PeachType.Mono,427 fontSize = 12.sp,428 )429 }430 inner()431 }432 },433 )434}435436@Composable437private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {438 val accent = LocalPeachAccent.current439 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"440 else "Create new entry"441 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }442 Row(443 modifier = Modifier444 .fillMaxWidth()445 .clickable(onClick = onClick)446 .padding(vertical = 10.dp),447 verticalAlignment = Alignment.CenterVertically,448 ) {449 Box(450 modifier = Modifier451 .size(34.dp)452 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),453 contentAlignment = Alignment.Center,454 ) {455 Text(456 text = "+",457 color = accent,458 fontFamily = PeachType.Display,459 fontStyle = FontStyle.Italic,460 fontSize = 18.sp,461 )462 }463 Column(modifier = Modifier.padding(start = 12.dp)) {464 Text(465 text = title,466 color = accent,467 fontFamily = PeachType.Body,468 fontSize = 14.sp,469 maxLines = 1,470 overflow = TextOverflow.Ellipsis,471 )472 if (subtitle != null) {473 Text(474 text = "prefill: $subtitle",475 color = peachColors.T2,476 fontFamily = PeachType.Mono,477 fontSize = 10.sp,478 maxLines = 1,479 overflow = TextOverflow.Ellipsis,480 )481 }482 }483 }484}485486@Composable487private fun UnlockBody(488 error: String?,489 busy: Boolean,490 canBiometric: Boolean,491 onBiometric: () -> Unit,492 onUnlock: (String) -> Unit,493) {494 val accent = LocalPeachAccent.current495 var password by remember { mutableStateOf("") }496497 Text(498 text = "Unlock to fill",499 color = peachColors.T0,500 fontFamily = PeachType.Body,501 fontSize = 15.sp,502 modifier = Modifier.padding(bottom = 10.dp),503 )504 BasicTextField(505 value = password,506 onValueChange = { password = it },507 singleLine = true,508 enabled = !busy,509 textStyle = TextStyle(510 color = peachColors.T0,511 fontFamily = PeachType.Mono,512 fontSize = 16.sp,513 ),514 cursorBrush = SolidColor(accent),515 visualTransformation = PasswordVisualTransformation(),516 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),517 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),518 modifier = Modifier519 .fillMaxWidth()520 .background(peachColors.S2, RoundedCornerShape(8.dp))521 .padding(horizontal = 12.dp, vertical = 12.dp),522 )523 if (error != null) {524 Text(525 text = error,526 color = peachColors.Breach,527 fontFamily = PeachType.Body,528 fontSize = 12.sp,529 modifier = Modifier.padding(top = 8.dp),530 )531 }532 Row(533 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),534 horizontalArrangement = Arrangement.spacedBy(20.dp),535 verticalAlignment = Alignment.CenterVertically,536 ) {537 if (canBiometric) {538 Text(539 text = "Use biometrics",540 color = accent,541 fontFamily = PeachType.Mono,542 fontSize = 13.sp,543 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),544 )545 }546 Text(547 text = if (busy) "Unlocking…" else "Unlock",548 color = if (password.isEmpty() || busy) peachColors.T2 else accent,549 fontFamily = PeachType.Mono,550 fontSize = 13.sp,551 modifier = Modifier552 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }553 .padding(vertical = 8.dp),554 )555 }556}557558@Composable559private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {560 Row(561 modifier = Modifier562 .fillMaxWidth()563 .clickable(onClick = onClick)564 .padding(vertical = 9.dp),565 verticalAlignment = Alignment.CenterVertically,566 ) {567 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)568 Column(modifier = Modifier.padding(start = 12.dp)) {569 Text(570 text = entry.title,571 color = peachColors.T0,572 fontFamily = PeachType.Body,573 fontSize = 15.sp,574 maxLines = 1,575 overflow = TextOverflow.Ellipsis,576 )577 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""578 if (sub.isNotBlank()) {579 Text(580 text = sub,581 color = peachColors.T2,582 fontFamily = PeachType.Mono,583 fontSize = 11.sp,584 maxLines = 1,585 overflow = TextOverflow.Ellipsis,586 )587 }588 }589 }590}591592593private const val SEARCH_DEBOUNCE_MS = 200L