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.MainActivity48import com.peachpasswords.shared.extractDomain49import com.peachpasswords.ui.lock.BiometricKeyStore50import com.peachpasswords.ui.lock.Biometrics51import com.peachpasswords.ui.components.FaviconInitials52import com.peachpasswords.ui.theme.ACCENT_COLORS53import com.peachpasswords.ui.theme.DEFAULT_ACCENT54import com.peachpasswords.ui.theme.LocalPeachAccent55import com.peachpasswords.ui.theme.peachColors56import com.peachpasswords.ui.theme.PeachTheme57import com.peachpasswords.ui.theme.PeachType58import com.peachpasswords.vault.UnlockResult59import com.peachpasswords.vault.VaultEntry60import com.peachpasswords.vault.VaultRepository61import dagger.hilt.android.AndroidEntryPoint62import kotlinx.coroutines.delay63import kotlinx.coroutines.launch64import javax.inject.Inject6566676869707172737475767778798081828384858687@AndroidEntryPoint88class AutofillEntryPickerActivity : FragmentActivity() {8990 @Inject lateinit var vault: VaultRepository9192 @OptIn(ExperimentalMaterial3Api::class)93 override fun onCreate(savedInstanceState: Bundle?) {94 super.onCreate(savedInstanceState)9596 val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)97 val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)98 val typedUsername = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_USERNAME)99 val typedEmail = intent.getStringExtra(AutofillIntents.EXTRA_TYPED_EMAIL)100 val parsed = ParsedStructure(101 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),102 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),103 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),104 webDomain = origin.takeIf { isWebOrigin },105 packageName = origin.takeUnless { isWebOrigin },106 )107 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT108109 setContent {110 PeachTheme(accent = accent) {111 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)112 val scope = rememberCoroutineScope()113 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }114 var error by remember { mutableStateOf<String?>(null) }115 var busy by remember { mutableStateOf(false) }116117 118 119 120 121 val allEntries = remember(unlocked) {122 if (unlocked) vault.getEntries() else emptyList()123 }124 val matchedEntries = remember(unlocked, allEntries) {125 if (unlocked) autofillPickerEntries(allEntries, parsed, MAX_ROWS) else emptyList()126 }127128 129 130 131 LaunchedEffect(unlocked) {132 if (!unlocked && canBiometric()) {133 tryBiometric(134 onUnlocked = { unlocked = true },135 onFail = { },136 )137 }138 }139140 ModalBottomSheet(141 onDismissRequest = { cancel() },142 sheetState = sheetState,143 containerColor = peachColors.S1,144 dragHandle = { AccentDragHandle() },145 ) {146 PickerBody(147 origin = parsed.origin,148 typedIdentity = typedUsername ?: typedEmail,149 unlocked = unlocked,150 matchedEntries = matchedEntries,151 allEntries = allEntries,152 error = error,153 busy = busy,154 canBiometric = canBiometric(),155 onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },156 onUnlock = { password ->157 busy = true158 error = null159 scope.launch {160 when (vault.unlock(password)) {161 is UnlockResult.Success -> unlocked = true162 UnlockResult.WrongPassword -> error = "Wrong master password."163 else -> error = "Couldn't unlock."164 }165 busy = false166 }167 },168 onPick = { returnDataset(parsed, it) },169 onCreateNew = { launchNewEntry(parsed, typedUsername, typedEmail) },170 )171 }172 }173 }174 }175176 private fun canBiometric(): Boolean =177 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)178179 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {180 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()181 Biometrics.authenticateCrypto(182 activity = this,183 title = "Unlock Peach",184 subtitle = "Fill your saved details",185 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),186 onSuccess = { result ->187 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()188 189 190 191 192 val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()193 lifecycleScope.launch {194 try {195 if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()196 } finally {197 com.peachpasswords.vault.VaultCrypto.zero(key)198 }199 }200 },201 onFail = onFail,202 )203 }204205 private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {206 val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)207 if (dataset == null) { cancel(); return }208 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)209 setResult(RESULT_OK, reply)210 finish()211 }212213 214215216217218219 private fun launchNewEntry(parsed: ParsedStructure, typedUsername: String?, typedEmail: String?) {220 val typedIdentity = typedUsername ?: typedEmail221 val intent = Intent(this, MainActivity::class.java).apply {222 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)223 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, parsed.origin)224 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)225 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)226 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)227 }228 startActivity(intent)229 cancel()230 }231232 private fun cancel() {233 setResult(RESULT_CANCELED)234 finish()235 }236237 @Suppress("DEPRECATION")238 private fun intentAutofillId(key: String): AutofillId? =239 if (android.os.Build.VERSION.SDK_INT >= 33) {240 intent.getParcelableExtra(key, AutofillId::class.java)241 } else {242 intent.getParcelableExtra(key)243 }244245 private companion object {246 const val MAX_ROWS = 12247 }248}249250internal fun autofillPickerEntries(251 entries: List<VaultEntry>,252 parsed: ParsedStructure,253 limit: Int,254): List<VaultEntry> = when {255 parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit)256 parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit)257 else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)258}259260261262263264265internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {266 if (query.isEmpty()) return true267 val needle = query.lowercase()268 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)269 .any { it.lowercase().contains(needle) }270}271272@Composable273private fun AccentDragHandle() {274 val accent = LocalPeachAccent.current275 Row(276 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),277 horizontalArrangement = Arrangement.Center,278 ) {279 Box(280 modifier = Modifier281 .size(width = 32.dp, height = 4.dp)282 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),283 )284 }285}286287@Composable288private fun PickerBody(289 origin: String?,290 typedIdentity: String?,291 unlocked: Boolean,292 matchedEntries: List<VaultEntry>,293 allEntries: List<VaultEntry>,294 error: String?,295 busy: Boolean,296 canBiometric: Boolean,297 onBiometric: () -> Unit,298 onUnlock: (String) -> Unit,299 onPick: (VaultEntry) -> Unit,300 onCreateNew: () -> Unit,301) {302 val accent = LocalPeachAccent.current303 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {304 Text(305 text = "peach",306 color = accent,307 fontFamily = PeachType.Display,308 fontStyle = FontStyle.Italic,309 fontSize = 15.sp,310 )311 Text(312 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",313 color = peachColors.T2,314 fontFamily = PeachType.Mono,315 fontSize = 9.sp,316 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),317 )318319 if (!unlocked) {320 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)321 } else {322 323 324 325 var rawQuery by remember { mutableStateOf("") }326 var debouncedQuery by remember { mutableStateOf("") }327 LaunchedEffect(rawQuery) {328 delay(SEARCH_DEBOUNCE_MS)329 debouncedQuery = rawQuery330 }331 SearchField(332 value = rawQuery,333 onValueChange = { rawQuery = it },334 placeholder = if (matchedEntries.isNotEmpty())335 "search for another entry…" else "search vault…",336 )337338 339 340 341 342 343 344 val visibleEntries = if (debouncedQuery.isBlank()) {345 matchedEntries346 } else {347 allEntries.filter { matchesPickerSearch(it, debouncedQuery) }348 }349 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()350351 if (matchedEntries.isEmpty() && debouncedQuery.isBlank()) {352 Text(353 text = "No saved entries match this site.",354 color = peachColors.T1,355 fontFamily = PeachType.Body,356 fontSize = 13.sp,357 modifier = Modifier.padding(vertical = 6.dp),358 )359 } else if (showSearchEmpty) {360 Text(361 text = "No entries match \"${debouncedQuery.trim()}\".",362 color = peachColors.T1,363 fontFamily = PeachType.Body,364 fontSize = 13.sp,365 modifier = Modifier.padding(vertical = 6.dp),366 )367 }368369 if (visibleEntries.isNotEmpty()) {370 LazyColumn {371 items(visibleEntries, key = { it.id }) { entry ->372 PickerRow(entry, onClick = { onPick(entry) })373 }374 }375 }376377 378 379 380 CreateNewRow(origin = origin, typedIdentity = typedIdentity, onClick = onCreateNew)381 }382 }383}384385@Composable386private fun SearchField(387 value: String,388 onValueChange: (String) -> Unit,389 placeholder: String,390) {391 val accent = LocalPeachAccent.current392 BasicTextField(393 value = value,394 onValueChange = onValueChange,395 singleLine = true,396 textStyle = TextStyle(397 color = peachColors.T0,398 fontFamily = PeachType.Mono,399 fontSize = 14.sp,400 ),401 cursorBrush = SolidColor(accent),402 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),403 keyboardActions = KeyboardActions(onSearch = { }),404 modifier = Modifier405 .fillMaxWidth()406 .background(peachColors.S2, RoundedCornerShape(8.dp))407 .padding(horizontal = 12.dp, vertical = 10.dp),408 decorationBox = { inner ->409 Box(modifier = Modifier.fillMaxWidth()) {410 if (value.isEmpty()) {411 Text(412 text = placeholder,413 color = peachColors.T2,414 fontFamily = PeachType.Mono,415 fontSize = 12.sp,416 )417 }418 inner()419 }420 },421 )422}423424@Composable425private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {426 val accent = LocalPeachAccent.current427 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"428 else "Create new entry"429 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }430 Row(431 modifier = Modifier432 .fillMaxWidth()433 .clickable(onClick = onClick)434 .padding(vertical = 10.dp),435 verticalAlignment = Alignment.CenterVertically,436 ) {437 Box(438 modifier = Modifier439 .size(34.dp)440 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),441 contentAlignment = Alignment.Center,442 ) {443 Text(444 text = "+",445 color = accent,446 fontFamily = PeachType.Display,447 fontStyle = FontStyle.Italic,448 fontSize = 18.sp,449 )450 }451 Column(modifier = Modifier.padding(start = 12.dp)) {452 Text(453 text = title,454 color = accent,455 fontFamily = PeachType.Body,456 fontSize = 14.sp,457 maxLines = 1,458 overflow = TextOverflow.Ellipsis,459 )460 if (subtitle != null) {461 Text(462 text = "prefill: $subtitle",463 color = peachColors.T2,464 fontFamily = PeachType.Mono,465 fontSize = 10.sp,466 maxLines = 1,467 overflow = TextOverflow.Ellipsis,468 )469 }470 }471 }472}473474@Composable475private fun UnlockBody(476 error: String?,477 busy: Boolean,478 canBiometric: Boolean,479 onBiometric: () -> Unit,480 onUnlock: (String) -> Unit,481) {482 val accent = LocalPeachAccent.current483 var password by remember { mutableStateOf("") }484485 Text(486 text = "Unlock to fill",487 color = peachColors.T0,488 fontFamily = PeachType.Body,489 fontSize = 15.sp,490 modifier = Modifier.padding(bottom = 10.dp),491 )492 BasicTextField(493 value = password,494 onValueChange = { password = it },495 singleLine = true,496 enabled = !busy,497 textStyle = TextStyle(498 color = peachColors.T0,499 fontFamily = PeachType.Mono,500 fontSize = 16.sp,501 ),502 cursorBrush = SolidColor(accent),503 visualTransformation = PasswordVisualTransformation(),504 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),505 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),506 modifier = Modifier507 .fillMaxWidth()508 .background(peachColors.S2, RoundedCornerShape(8.dp))509 .padding(horizontal = 12.dp, vertical = 12.dp),510 )511 if (error != null) {512 Text(513 text = error,514 color = peachColors.Breach,515 fontFamily = PeachType.Body,516 fontSize = 12.sp,517 modifier = Modifier.padding(top = 8.dp),518 )519 }520 Row(521 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),522 horizontalArrangement = Arrangement.spacedBy(20.dp),523 verticalAlignment = Alignment.CenterVertically,524 ) {525 if (canBiometric) {526 Text(527 text = "Use biometrics",528 color = accent,529 fontFamily = PeachType.Mono,530 fontSize = 13.sp,531 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),532 )533 }534 Text(535 text = if (busy) "Unlocking…" else "Unlock",536 color = if (password.isEmpty() || busy) peachColors.T2 else accent,537 fontFamily = PeachType.Mono,538 fontSize = 13.sp,539 modifier = Modifier540 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }541 .padding(vertical = 8.dp),542 )543 }544}545546@Composable547private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {548 Row(549 modifier = Modifier550 .fillMaxWidth()551 .clickable(onClick = onClick)552 .padding(vertical = 9.dp),553 verticalAlignment = Alignment.CenterVertically,554 ) {555 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)556 Column(modifier = Modifier.padding(start = 12.dp)) {557 Text(558 text = entry.title,559 color = peachColors.T0,560 fontFamily = PeachType.Body,561 fontSize = 15.sp,562 maxLines = 1,563 overflow = TextOverflow.Ellipsis,564 )565 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""566 if (sub.isNotBlank()) {567 Text(568 text = sub,569 color = peachColors.T2,570 fontFamily = PeachType.Mono,571 fontSize = 11.sp,572 maxLines = 1,573 overflow = TextOverflow.Ellipsis,574 )575 }576 }577 }578}579580581private const val SEARCH_DEBOUNCE_MS = 200L