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.graphics.Color42import 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.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.launch63import javax.inject.Inject6465666768697071727374757677787980@AndroidEntryPoint81class AutofillEntryPickerActivity : FragmentActivity() {8283 @Inject lateinit var vault: VaultRepository8485 @OptIn(ExperimentalMaterial3Api::class)86 override fun onCreate(savedInstanceState: Bundle?) {87 super.onCreate(savedInstanceState)8889 val origin = intent.getStringExtra(AutofillIntents.EXTRA_ORIGIN)90 val isWebOrigin = intent.getBooleanExtra(AutofillIntents.EXTRA_IS_WEB_ORIGIN, false)91 val parsed = ParsedStructure(92 usernameId = intentAutofillId(AutofillIntents.EXTRA_IDENTITY_ID),93 passwordId = intentAutofillId(AutofillIntents.EXTRA_PASSWORD_ID),94 otpId = intentAutofillId(AutofillIntents.EXTRA_OTP_ID),95 webDomain = origin.takeIf { isWebOrigin },96 packageName = origin.takeUnless { isWebOrigin },97 )98 val accent = vault.settings.value.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT99100 setContent {101 PeachTheme(accent = accent) {102 val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)103 val scope = rememberCoroutineScope()104 var unlocked by remember { mutableStateOf(vault.isUnlocked.value) }105 var error by remember { mutableStateOf<String?>(null) }106 var busy by remember { mutableStateOf(false) }107108 val entries = remember(unlocked) {109 if (unlocked) autofillPickerEntries(vault.getEntries(), parsed, MAX_ROWS) else emptyList()110 }111112 113 114 115 LaunchedEffect(unlocked) {116 if (!unlocked && canBiometric()) {117 tryBiometric(118 onUnlocked = { unlocked = true },119 onFail = { },120 )121 }122 }123124 ModalBottomSheet(125 onDismissRequest = { cancel() },126 sheetState = sheetState,127 containerColor = peachColors.S1,128 dragHandle = { AccentDragHandle() },129 ) {130 PickerBody(131 origin = parsed.origin,132 unlocked = unlocked,133 entries = entries,134 error = error,135 busy = busy,136 canBiometric = canBiometric(),137 onBiometric = { tryBiometric(onUnlocked = { unlocked = true }, onFail = {}) },138 onUnlock = { password ->139 busy = true140 error = null141 scope.launch {142 when (vault.unlock(password)) {143 is UnlockResult.Success -> unlocked = true144 UnlockResult.WrongPassword -> error = "Wrong master password."145 else -> error = "Couldn't unlock."146 }147 busy = false148 }149 },150 onPick = { returnDataset(parsed, it) },151 )152 }153 }154 }155 }156157 private fun canBiometric(): Boolean =158 Biometrics.strongAvailable(this) && BiometricKeyStore.hasWrappedKey(this)159160 private fun tryBiometric(onUnlocked: () -> Unit, onFail: () -> Unit) {161 val cipher = BiometricKeyStore.decryptCipher(this) ?: return onFail()162 Biometrics.authenticateCrypto(163 activity = this,164 title = "Unlock Peach",165 subtitle = "Fill your saved details",166 crypto = androidx.biometric.BiometricPrompt.CryptoObject(cipher),167 onSuccess = { result ->168 val used = result.cryptoObject?.cipher ?: return@authenticateCrypto onFail()169 val key = BiometricKeyStore.unwrap(this, used) ?: return@authenticateCrypto onFail()170 lifecycleScope.launch {171 if (vault.unlockWithKey(key) is UnlockResult.Success) onUnlocked() else onFail()172 }173 },174 onFail = onFail,175 )176 }177178 private fun returnDataset(parsed: ParsedStructure, entry: VaultEntry) {179 val dataset = AutofillResponseBuilder.datasetFor(this, parsed, entry, inlineSpec = null)180 if (dataset == null) { cancel(); return }181 val reply = Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)182 setResult(RESULT_OK, reply)183 finish()184 }185186 private fun cancel() {187 setResult(RESULT_CANCELED)188 finish()189 }190191 @Suppress("DEPRECATION")192 private fun intentAutofillId(key: String): AutofillId? =193 if (android.os.Build.VERSION.SDK_INT >= 33) {194 intent.getParcelableExtra(key, AutofillId::class.java)195 } else {196 intent.getParcelableExtra(key)197 }198199 private companion object {200 const val MAX_ROWS = 12201 }202}203204internal fun autofillPickerEntries(205 entries: List<VaultEntry>,206 parsed: ParsedStructure,207 limit: Int,208): List<VaultEntry> = when {209 parsed.webDomain != null -> AutofillResponseBuilder.matchEntries(entries, parsed.webDomain).all.take(limit)210 parsed.packageName != null -> entries.filter { it.url == parsed.packageName }.take(limit)211 else -> AutofillResponseBuilder.matchEntries(entries, null).all.take(limit)212}213214@Composable215private fun AccentDragHandle() {216 val accent = LocalPeachAccent.current217 Row(218 modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 6.dp),219 horizontalArrangement = Arrangement.Center,220 ) {221 Box(222 modifier = Modifier223 .size(width = 32.dp, height = 4.dp)224 .background(accent.copy(alpha = 0.7f), RoundedCornerShape(2.dp)),225 )226 }227}228229@Composable230private fun PickerBody(231 origin: String?,232 unlocked: Boolean,233 entries: List<VaultEntry>,234 error: String?,235 busy: Boolean,236 canBiometric: Boolean,237 onBiometric: () -> Unit,238 onUnlock: (String) -> Unit,239 onPick: (VaultEntry) -> Unit,240) {241 val accent = LocalPeachAccent.current242 Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {243 Text(244 text = "peach",245 color = accent,246 fontFamily = PeachType.Display,247 fontStyle = FontStyle.Italic,248 fontSize = 15.sp,249 )250 Text(251 text = if (origin.isNullOrBlank()) "your vault" else "Suggested for $origin",252 color = peachColors.T2,253 fontFamily = PeachType.Mono,254 fontSize = 9.sp,255 modifier = Modifier.padding(top = 2.dp, bottom = 14.dp),256 )257258 if (!unlocked) {259 UnlockBody(error, busy, canBiometric, onBiometric, onUnlock)260 } else if (entries.isEmpty()) {261 Text(262 text = "No saved entries match this site.",263 color = peachColors.T1,264 fontFamily = PeachType.Body,265 fontSize = 14.sp,266 )267 } else {268 LazyColumn {269 items(entries, key = { it.id }) { entry ->270 PickerRow(entry, onClick = { onPick(entry) })271 }272 }273 }274 }275}276277@Composable278private fun UnlockBody(279 error: String?,280 busy: Boolean,281 canBiometric: Boolean,282 onBiometric: () -> Unit,283 onUnlock: (String) -> Unit,284) {285 val accent = LocalPeachAccent.current286 var password by remember { mutableStateOf("") }287288 Text(289 text = "Unlock to fill",290 color = peachColors.T0,291 fontFamily = PeachType.Body,292 fontSize = 15.sp,293 modifier = Modifier.padding(bottom = 10.dp),294 )295 BasicTextField(296 value = password,297 onValueChange = { password = it },298 singleLine = true,299 enabled = !busy,300 textStyle = TextStyle(301 color = peachColors.T0,302 fontFamily = PeachType.Mono,303 fontSize = 16.sp,304 ),305 cursorBrush = SolidColor(accent),306 visualTransformation = PasswordVisualTransformation(),307 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),308 keyboardActions = KeyboardActions(onGo = { if (password.isNotEmpty()) onUnlock(password) }),309 modifier = Modifier310 .fillMaxWidth()311 .background(peachColors.S2, RoundedCornerShape(8.dp))312 .padding(horizontal = 12.dp, vertical = 12.dp),313 )314 if (error != null) {315 Text(316 text = error,317 color = peachColors.Breach,318 fontFamily = PeachType.Body,319 fontSize = 12.sp,320 modifier = Modifier.padding(top = 8.dp),321 )322 }323 Row(324 modifier = Modifier.fillMaxWidth().padding(top = 14.dp),325 horizontalArrangement = Arrangement.spacedBy(20.dp),326 verticalAlignment = Alignment.CenterVertically,327 ) {328 if (canBiometric) {329 Text(330 text = "Use biometrics",331 color = accent,332 fontFamily = PeachType.Mono,333 fontSize = 13.sp,334 modifier = Modifier.clickable(enabled = !busy, onClick = onBiometric).padding(vertical = 8.dp),335 )336 }337 Text(338 text = if (busy) "Unlocking…" else "Unlock",339 color = if (password.isEmpty() || busy) peachColors.T2 else accent,340 fontFamily = PeachType.Mono,341 fontSize = 13.sp,342 modifier = Modifier343 .clickable(enabled = password.isNotEmpty() && !busy) { onUnlock(password) }344 .padding(vertical = 8.dp),345 )346 }347}348349@Composable350private fun PickerRow(entry: VaultEntry, onClick: () -> Unit) {351 Row(352 modifier = Modifier353 .fillMaxWidth()354 .clickable(onClick = onClick)355 .padding(vertical = 9.dp),356 verticalAlignment = Alignment.CenterVertically,357 ) {358 FaviconInitials(source = entry.url ?: entry.title, size = 34.dp)359 Column(modifier = Modifier.padding(start = 12.dp)) {360 Text(361 text = entry.title,362 color = peachColors.T0,363 fontFamily = PeachType.Body,364 fontSize = 15.sp,365 maxLines = 1,366 overflow = TextOverflow.Ellipsis,367 )368 val sub = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""369 if (sub.isNotBlank()) {370 Text(371 text = sub,372 color = peachColors.T2,373 fontFamily = PeachType.Mono,374 fontSize = 11.sp,375 maxLines = 1,376 overflow = TextOverflow.Ellipsis,377 )378 }379 }380 }381}