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 val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()189 lifecycleScope.launch {190 if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()191 }192 },193 onFail = onFail,194 )195 }196197 private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {198 val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)199 if (dataset == null) { cancel(); return }200 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)201 setResult(RESULT_OK, reply)202 finish()203 }204205 206207208209210211 private fun launchNewEntry(parsed: ParsedStructure, typedUsername: String?, typedEmail: String?) {212 val typedIdentity = typedUsername ?: typedEmail213 val intent = Intent(this, MainActivity::class.java).apply {214 putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)215 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, parsed.origin)216 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)217 putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)218 addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)219 }220 startActivity(intent)221 cancel()222 }223224 private fun cancel() {225 setResult(RESULT_CANCELED)226 finish()227 }228229 @Suppress("DEPRECATION")230 private fun intentAutofillId(key: String): AutofillId? =231 if (android.os.Build.VERSION.SDK_INT >= 33) {232 intent.getParcelableExtra(key, AutofillId::class.java)233 } else {234 intent.getParcelableExtra(key)235 }236237 private companion object {238 const val MAX_ROWS = 12239 }240}241242internal fun autofillPickerEntries(243 entries: List<VaultEntry>,244 parsed: ParsedStructure,245 limit: Int,246): List<VaultEntry> = when {247 parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit)248 parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit)249 else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)250}251252253254255256257internal fun matchesPickerSearch(entry: VaultEntry, query: String): Boolean {258 if (query.isEmpty()) return true259 val needle = query.lowercase()260 return listOfNotNull(entry.title, entry.username, entry.email, entry.url)261 .any { it.lowercase().contains(needle) }262}263264@Composable265private fun AccentDragHandle() {266 val accent = LocalPeachAccent.current267 Row(268 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),269 horizontalArrangement = Arrangement.Center,270 ) {271 Box(272 modifier = Modifier273 .size(width = 32.dp, height = 4.dp)274 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),275 )276 }277}278279@Composable280private fun PickerBody(281 origin: String?,282 typedIdentity: String?,283 unlocked: Boolean,284 matchedEntries: List<VaultEntry>,285 allEntries: List<VaultEntry>,286 error: String?,287 busy: Boolean,288 canBiometric: Boolean,289 onBiometric: () -> Unit,290 onUnlock: (String) -> Unit,291 onPick: (VaultEntry) -> Unit,292 onCreateNew: () -> Unit,293) {294 val accent = LocalPeachAccent.current295 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {296 Text(297 text = "peach",298 color = accent,299 fontFamily = PeachType.Display,300 fontStyle = FontStyle.Italic,301 fontSize = 15.sp,302 )303 Text(304 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",305 color = peachColors.T2,306 fontFamily = PeachType.Mono,307 fontSize = 9.sp,308 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),309 )310311 if (!unlocked) {312 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)313 } else {314 315 316 317 var rawQuery by remember { mutableStateOf("") }318 var debouncedQuery by remember { mutableStateOf("") }319 LaunchedEffect(rawQuery) {320 delay(SEARCH_DEBOUNCE_MS)321 debouncedQuery = rawQuery322 }323 SearchField(324 value = rawQuery,325 onValueChange = { rawQuery = it },326 placeholder = if (matchedEntries.isNotEmpty())327 "search for another entry…" else "search vault…",328 )329330 331 332 333 334 335 336 val visibleEntries = if (debouncedQuery.isBlank()) {337 matchedEntries338 } else {339 allEntries.filter { matchesPickerSearch(it, debouncedQuery) }340 }341 val showSearchEmpty = debouncedQuery.isNotBlank() && visibleEntries.isEmpty()342343 if (matchedEntries.isEmpty() && debouncedQuery.isBlank()) {344 Text(345 text = "No saved entries match this site.",346 color = peachColors.T1,347 fontFamily = PeachType.Body,348 fontSize = 13.sp,349 modifier = Modifier.padding(vertical = 6.dp),350 )351 } else if (showSearchEmpty) {352 Text(353 text = "No entries match \"${debouncedQuery.trim()}\".",354 color = peachColors.T1,355 fontFamily = PeachType.Body,356 fontSize = 13.sp,357 modifier = Modifier.padding(vertical = 6.dp),358 )359 }360361 if (visibleEntries.isNotEmpty()) {362 LazyColumn {363 items(visibleEntries, key = { it.id }) { entry ->364 PickerRow(entry, onClick = { onPick(entry) })365 }366 }367 }368369 370 371 372 CreateNewRow(origin = origin, typedIdentity = typedIdentity, onClick = onCreateNew)373 }374 }375}376377@Composable378private fun SearchField(379 value: String,380 onValueChange: (String) -> Unit,381 placeholder: String,382) {383 val accent = LocalPeachAccent.current384 BasicTextField(385 value = value,386 onValueChange = onValueChange,387 singleLine = true,388 textStyle = TextStyle(389 color = peachColors.T0,390 fontFamily = PeachType.Mono,391 fontSize = 14.sp,392 ),393 cursorBrush = SolidColor(accent),394 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),395 keyboardActions = KeyboardActions(onSearch = { }),396 modifier = Modifier397 .fillMaxWidth()398 .background(peachColors.S2, RoundedCornerShape(8.dp))399 .padding(horizontal = 12.dp, vertical = 10.dp),400 decorationBox = { inner ->401 Box(modifier = Modifier.fillMaxWidth()) {402 if (value.isEmpty()) {403 Text(404 text = placeholder,405 color = peachColors.T2,406 fontFamily = PeachType.Mono,407 fontSize = 12.sp,408 )409 }410 inner()411 }412 },413 )414}415416@Composable417private fun CreateNewRow(origin: String?, typedIdentity: String?, onClick: () -> Unit) {418 val accent = LocalPeachAccent.current419 val title = if (!origin.isNullOrBlank()) "Create new entry for $origin"420 else "Create new entry"421 val subtitle = typedIdentity?.takeIf { it.isNotBlank() }422 Row(423 modifier = Modifier424 .fillMaxWidth()425 .clickable(onClick = onClick)426 .padding(vertical = 10.dp),427 verticalAlignment = Alignment.CenterVertically,428 ) {429 Box(430 modifier = Modifier431 .size(34.dp)432 .background(accent.copy(alpha = 0.15f), RoundedCornerShape(8.dp)),433 contentAlignment = Alignment.Center,434 ) {435 Text(436 text = "+",437 color = accent,438 fontFamily = PeachType.Display,439 fontStyle = FontStyle.Italic,440 fontSize = 18.sp,441 )442 }443 Column(modifier = Modifier.padding(start = 12.dp)) {444 Text(445 text = title,446 color = accent,447 fontFamily = PeachType.Body,448 fontSize = 14.sp,449 maxLines = 1,450 overflow = TextOverflow.Ellipsis,451 )452 if (subtitle != null) {453 Text(454 text = "prefill: $subtitle",455 color = peachColors.T2,456 fontFamily = PeachType.Mono,457 fontSize = 10.sp,458 maxLines = 1,459 overflow = TextOverflow.Ellipsis,460 )461 }462 }463 }464}465466@Composable467private fun UnlockBody(468 error: String?,469 busy: Boolean,470 canBiometric: Boolean,471 onBiometric: () -> Unit,472 onUnlock: (String) -> Unit,473) {474 val accent = LocalPeachAccent.current475 var password by remember { mutableStateOf("") }476477 Text(478 text = "Unlock to fill",479 color = peachColors.T0,480 fontFamily = PeachType.Body,481 fontSize = 15.sp,482 modifier = Modifier.padding(bottom = 10.dp),483 )484 BasicTextField(485 value = password,486 onValueChange = { password = it },487 singleLine = true,488 enabled = !busy,489 textStyle = TextStyle(490 color = peachColors.T0,491 fontFamily = PeachType.Mono,492 fontSize = 16.sp,493 ),494 cursorBrush = SolidColor(accent),495 visualTransformation = PasswordVisualTransformation(),496 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),497 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),498 modifier = Modifier499 .fillMaxWidth()500 .background(peachColors.S2, RoundedCornerShape(8.dp))501 .padding(horizontal = 12.dp, vertical = 12.dp),502 )503 if (error != null) {504 Text(505 text = error,506 color = peachColors.Breach,507 fontFamily = PeachType.Body,508 fontSize = 12.sp,509 modifier = Modifier.padding(top = 8.dp),510 )511 }512 Row(513 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),514 horizontalArrangement = Arrangement.spacedBy(20.dp),515 verticalAlignment = Alignment.CenterVertically,516 ) {517 if (canBiometric) {518 Text(519 text = "Use biometrics",520 color = accent,521 fontFamily = PeachType.Mono,522 fontSize = 13.sp,523 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),524 )525 }526 Text(527 text = if (busy) "Unlocking…" else "Unlock",528 color = if (password.isEmpty() || busy) peachColors.T2 else accent,529 fontFamily = PeachType.Mono,530 fontSize = 13.sp,531 modifier = Modifier532 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }533 .padding(vertical = 8.dp),534 )535 }536}537538@Composable539private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {540 Row(541 modifier = Modifier542 .fillMaxWidth()543 .clickable(onClick = onClick)544 .padding(vertical = 9.dp),545 verticalAlignment = Alignment.CenterVertically,546 ) {547 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)548 Column(modifier = Modifier.padding(start = 12.dp)) {549 Text(550 text = entry.title,551 color = peachColors.T0,552 fontFamily = PeachType.Body,553 fontSize = 15.sp,554 maxLines = 1,555 overflow = TextOverflow.Ellipsis,556 )557 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""558 if (sub.isNotBlank()) {559 Text(560 text = sub,561 color = peachColors.T2,562 fontFamily = PeachType.Mono,563 fontSize = 11.sp,564 maxLines = 1,565 overflow = TextOverflow.Ellipsis,566 )567 }568 }569 }570}571572573private const val SEARCH_DEBOUNCE_MS = 200L