1package com.peachpasswords.autofill23import android.annotation.SuppressLint4import android.app.PendingIntent5import android.content.Context6import android.os.Build7import android.service.autofill.Dataset8import android.service.autofill.InlinePresentation9import android.view.autofill.AutofillId10import android.view.autofill.AutofillValue11import android.widget.RemoteViews12import androidx.annotation.RequiresApi13import android.graphics.Bitmap14import android.graphics.Canvas15import android.graphics.Paint16import android.graphics.drawable.Icon17import android.util.LruCache18import androidx.autofill.inline.v1.InlineSuggestionUi19import androidx.autofill.inline.UiVersions20import com.peachpasswords.R21import com.peachpasswords.shared.extractDomain22import com.peachpasswords.vault.PasswordGenerator23import com.peachpasswords.vault.TOTPGenerator24import com.peachpasswords.vault.VaultEntry25import com.peachpasswords.vault.isCard262728293031323334@Suppress("DEPRECATION")35object AutofillResponseBuilder {3637 38394041 data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {42 val all: List<VaultEntry> get() = hits + rest43 }4445 fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {46 val originDomain = origin?.let(::extractDomain)47 ?: return MatchResult(emptyList(), emptyList())48 return matchDomains(entries, setOf(originDomain))49 }5051 52535455565758 fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {59 val byDomain = matchDomains(entries, parsed.originDomains.mapNotNull(::extractDomain).toSet()).hits60 val byPackage = nativePackageMatches(entries, parsed)61 return MatchResult((byDomain + byPackage).distinctBy { it.id }, emptyList())62 }6364 6566676869707172 private fun nativePackageMatches(entries: List<VaultEntry>, parsed: ParsedStructure): List<VaultEntry> {73 val packageName = parsed.packageName?.takeIf { it.isNotBlank() } ?: return emptyList()74 if (parsed.webDomain != null) return emptyList()75 val signerDigests = parsed.nativeSignerDigests76 if (signerDigests.isNullOrEmpty()) return emptyList()77 return entries.filter { entry ->78 (entry.url == packageName || entry.additionalUrls.orEmpty().any { it == packageName }) &&79 entry.nativeSignerDigests.orEmpty().any { it in signerDigests }80 }81 }8283 private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =84 MatchResult(85 hits = entries.filter { entry ->86 domains.any { domain -> entry.matchesOriginDomain(domain) }87 },88 rest = emptyList(),89 )9091 92 internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =93 code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }9495 private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =96 sequenceOf(url)97 .plus(additionalUrls.orEmpty().asSequence())98 .filterNotNull()99 .mapNotNull(::extractDomain)100 .any { it == originDomain }101102 103104105106107 enum class FillMode { LOGIN, OTP_FILL }108109 data class PreviewLabels(110 val title: String,111 val subtitle: String,112 val iconSource: String?,113 val fillMode: FillMode = FillMode.LOGIN,114 val endIcon: Icon? = null,115 )116117 118119120121122 123124125126127128129 internal fun chunkOtpForDisplay(code: String): String {130 if (code.length != 6 || !code.all { it.isDigit() }) return code131 return code.chunked(3).joinToString(" ")132 }133134 135136137138139140141142143 fun previewLabels(144 privatePreview: Boolean,145 entry: VaultEntry,146 originDomain: String?,147 fillMode: FillMode = FillMode.LOGIN,148 otpCode: String? = null,149 secondsRemaining: Int? = null,150 ): PreviewLabels = when {151 privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)152 fillMode == FillMode.OTP_FILL -> {153 val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" }154 155 156 157 158 159 160 161 162 163 val subtitle = when {164 secondsRemaining == null -> "2FA"165 secondsRemaining <= 0 -> "2FA · expired"166 else -> "2FA · ${secondsRemaining}s"167 }168 val endIcon = secondsRemaining?.let { OtpCountdownDot.icon(it) }169 PreviewLabels(170 title = chunkOtpForDisplay(code),171 subtitle = subtitle,172 iconSource = entry.title,173 fillMode = FillMode.OTP_FILL,174 endIcon = endIcon,175 )176 }177 else -> PreviewLabels(178 entry.title,179 entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",180 entry.title,181 FillMode.LOGIN,182 )183 }184185 fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =186 RemoteViews(context.packageName, R.layout.autofill_row).apply {187 setTextViewText(R.id.autofill_row_title, labels.title)188 setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)189 }190191 fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =192 RemoteViews(context.packageName, R.layout.autofill_row).apply {193 setTextViewText(R.id.autofill_row_title, title)194 setTextViewText(R.id.autofill_row_subtitle, subtitle)195 }196197 198199200201 fun datasetFor(202 context: Context,203 parsed: ParsedStructure,204 entry: VaultEntry,205 inlineSpec: android.widget.inline.InlinePresentationSpec?,206 privatePreview: Boolean,207 ): Dataset? {208 209 210 211 212 val otp = entry.totp?.let { TOTPGenerator.generate(it) }213 val otpCode = otp?.code214 val fillingOnlyOtp = parsed.identityId == null &&215 parsed.passwordId == null &&216 parsed.otpId != null &&217 otpCode != null218 val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN219 val labels = previewLabels(220 privatePreview,221 entry,222 parsed.origin?.let(::extractDomain),223 fillMode = fillMode,224 otpCode = if (fillingOnlyOtp) otpCode else null,225 secondsRemaining = otp?.remaining,226 )227 val menu = rowFor(context, labels)228 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {229 buildInline(context, labels, inlineSpec)230 } else {231 null232 }233234 val builder = Dataset.Builder()235 var filledAny = false236 fun put(id: AutofillId?, value: String?) {237 if (id == null || value.isNullOrEmpty()) return238 val av = AutofillValue.forText(value)239 if (inline != null && Build.VERSION.SDK_INT >= 30) {240 builder.setValue(id, av, menu, inline)241 } else {242 builder.setValue(id, av, menu)243 }244 filledAny = true245 }246 put(parsed.identityId, entry.username ?: entry.email)247 put(parsed.passwordId, entry.password)248 249 250 251 252 253 254 255 if (fillingOnlyOtp) put(parsed.otpId, otpCode)256 257 258 if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {259 otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->260 builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)261 }262 filledAny = true263 }264265 return if (filledAny) builder.build() else null266 }267268 269270271272273274275276277278279 fun otpDatasetFor(280 context: Context,281 parsed: ParsedStructure,282 entry: VaultEntry,283 inlineSpec: android.widget.inline.InlinePresentationSpec?,284 privatePreview: Boolean,285 ): Dataset? {286 val otpId = parsed.otpId ?: return null287 val otp = entry.totp?.let { TOTPGenerator.generate(it) } ?: return null288 val otpCode = otp.code289 val labels = previewLabels(290 privatePreview = privatePreview,291 entry = entry,292 originDomain = parsed.origin?.let(::extractDomain),293 fillMode = FillMode.OTP_FILL,294 otpCode = otpCode,295 secondsRemaining = otp.remaining,296 )297 val menu = rowFor(context, labels)298 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {299 buildInline(context, labels, inlineSpec)300 } else {301 null302 }303 val builder = Dataset.Builder()304 val av = AutofillValue.forText(otpCode)305 if (inline != null && Build.VERSION.SDK_INT >= 30) {306 builder.setValue(otpId, av, menu, inline)307 } else {308 builder.setValue(otpId, av, menu)309 }310 return builder.build()311 }312313 314315316317 fun cardDatasetFor(318 context: Context,319 parsed: ParsedStructure,320 entry: VaultEntry,321 inlineSpec: android.widget.inline.InlinePresentationSpec?,322 privatePreview: Boolean,323 ): Dataset? {324 if (!entry.isCard()) return null325 val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))326 val menu = rowFor(context, labels)327 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {328 buildInline(context, labels, inlineSpec)329 } else {330 null331 }332333 val builder = Dataset.Builder()334 var filledAny = false335 fun put(id: AutofillId?, value: String?) {336 if (id == null || value.isNullOrEmpty()) return337 val av = AutofillValue.forText(value)338 if (inline != null && Build.VERSION.SDK_INT >= 30) {339 builder.setValue(id, av, menu, inline)340 } else {341 builder.setValue(id, av, menu)342 }343 filledAny = true344 }345346 val digits = entry.cardNumber?.filter { it.isDigit() }347 put(parsed.cardNumberId, digits)348 put(parsed.cardExpMonthId, entry.expMonth)349 put(parsed.cardExpYearId, entry.expYear)350 if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {351 val yy = entry.expYear.takeLast(2)352 put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")353 }354 put(parsed.cardCvcId, entry.cvc)355 put(parsed.cardNameId, entry.cardholderName)356357 return if (filledAny) builder.build() else null358 }359360 361362363364365366367368 fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {369 val passwordId = parsed.passwordId ?: return null370 val policy = parsed.passwordPolicy371 val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH372 val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))373 val note = policy?.constraintNote(length).orEmpty()374 val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })375 return Dataset.Builder()376 .setValue(passwordId, AutofillValue.forText(password), menu)377 .build()378 }379380 381382383384385 fun identityDatasetFor(386 context: Context,387 parsed: ParsedStructure,388 identity: SavedIdentity,389 ): Dataset? {390 val targetId = when (identity.type) {391 IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId392 IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId393 } ?: return null394 val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"395 val menu = simpleRow(context, identity.value, subtitle)396 return Dataset.Builder()397 .setValue(targetId, AutofillValue.forText(identity.value), menu)398 .build()399 }400401 402403404405406407408409410411 fun searchPeachDataset(412 context: Context,413 parsed: ParsedStructure,414 inlineSpec: android.widget.inline.InlinePresentationSpec?,415 unlocked: Boolean,416 hasMatches: Boolean,417 requestId: Int,418 ): Dataset {419 val labels = if (unlocked) {420 PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)421 } else {422 PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)423 }424 val menu = simpleRow(context, labels.title, labels.subtitle)425 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {426 buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed, requestId))427 } else {428 null429 }430 val builder = Dataset.Builder(menu)431 val fieldIds = listOfNotNull(432 parsed.identityId, parsed.passwordId, parsed.otpId,433 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,434 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,435 ) + parsed.otpDigitBoxIds436 if (inline != null && Build.VERSION.SDK_INT >= 30) {437 438 439 440 fieldIds.firstOrNull()?.let { id ->441 builder.setValue(id, AutofillValue.forText(""), menu, inline)442 }443 fieldIds.drop(1).forEach { id -> builder.setValue(id, null, menu) }444 } else {445 fieldIds.forEach { id -> builder.setValue(id, null, menu) }446 }447 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed, requestId))448 return builder.build()449 }450451 452 453 @SuppressLint("RestrictedApi")454 @RequiresApi(30)455 private fun buildInline(456 context: Context,457 labels: PreviewLabels,458 spec: android.widget.inline.InlinePresentationSpec,459 chipPending: PendingIntent = AutofillIntents.noopChipIntent(context, 0),460 ): InlinePresentation? = try {461 val style = spec.style462 if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {463 null464 } else {465 val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }466 ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)467 val slice = InlineSuggestionUi.newContentBuilder(chipPending)468 .setTitle(labels.title)469 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }470 .setStartIcon(startIcon)471 .apply { if (labels.endIcon != null) setEndIcon(labels.endIcon) }472 .build()473 .slice474 InlinePresentation(slice, spec, false)475 }476 } catch (_: Throwable) {477 478 null479 }480481 482483484485486487488489490491492493 private object OtpCountdownDot {494 private const val SIZE_PX = 32495 private const val GREEN = 0xFF6FB07F.toInt()496 private const val AMBER = 0xFFE0A857.toInt()497 private const val RED = 0xFFD8534B.toInt()498 private val cache = LruCache<Int, Icon>(8)499500 fun icon(secondsRemaining: Int): Icon? {501 val color = when {502 secondsRemaining <= 4 -> RED503 secondsRemaining <= 9 -> AMBER504 else -> GREEN505 }506 cache.get(color)?.let { return it }507 508 509 510 511 512 val icon = runCatching {513 val bm = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)514 val canvas = Canvas(bm)515 val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }516 canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f - 2f, paint)517 Icon.createWithBitmap(bm)518 }.getOrNull() ?: return null519 cache.put(color, icon)520 return icon521 }522 }523}