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.isCard26272829303132object AutofillResponseBuilder {3334 35363738 data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {39 val all: List<VaultEntry> get() = hits + rest40 }4142 fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {43 val originDomain = origin?.let(::extractDomain)44 ?: return MatchResult(emptyList(), emptyList())45 return matchDomains(entries, setOf(originDomain))46 }4748 4950515253 fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {54 val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()55 val byDomain = matchDomains(entries, domains).hits56 val appTitle = parsed.appTitle?.trim()57 val byTitle = if (appTitle.isNullOrBlank()) {58 emptyList()59 } else {60 entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) }61 }62 return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList())63 }6465 private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =66 MatchResult(67 hits = entries.filter { entry ->68 domains.any { domain -> entry.matchesOriginDomain(domain) }69 },70 rest = emptyList(),71 )7273 74 internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =75 code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }7677 private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =78 sequenceOf(url)79 .plus(additionalUrls.orEmpty().asSequence())80 .filterNotNull()81 .mapNotNull(::extractDomain)82 .any { it == originDomain }8384 8586878889 enum class FillMode { LOGIN, OTP_FILL }9091 data class PreviewLabels(92 val title: String,93 val subtitle: String,94 val iconSource: String?,95 val fillMode: FillMode = FillMode.LOGIN,96 val endIcon: Icon? = null,97 )9899 100101102103104 105106107108109110111 internal fun chunkOtpForDisplay(code: String): String {112 if (code.length != 6 || !code.all { it.isDigit() }) return code113 return code.chunked(3).joinToString(" ")114 }115116 117118119120121122123124125 fun previewLabels(126 privatePreview: Boolean,127 entry: VaultEntry,128 originDomain: String?,129 fillMode: FillMode = FillMode.LOGIN,130 otpCode: String? = null,131 secondsRemaining: Int? = null,132 ): PreviewLabels = when {133 privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)134 fillMode == FillMode.OTP_FILL -> {135 val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" }136 137 138 139 140 141 142 143 144 145 val subtitle = when {146 secondsRemaining == null -> "2FA"147 secondsRemaining <= 0 -> "2FA · expired"148 else -> "2FA · ${secondsRemaining}s"149 }150 val endIcon = secondsRemaining?.let { OtpCountdownDot.icon(it) }151 PreviewLabels(152 title = chunkOtpForDisplay(code),153 subtitle = subtitle,154 iconSource = entry.title,155 fillMode = FillMode.OTP_FILL,156 endIcon = endIcon,157 )158 }159 else -> PreviewLabels(160 entry.title,161 entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",162 entry.title,163 FillMode.LOGIN,164 )165 }166167 fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =168 RemoteViews(context.packageName, R.layout.autofill_row).apply {169 setTextViewText(R.id.autofill_row_title, labels.title)170 setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)171 }172173 fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =174 RemoteViews(context.packageName, R.layout.autofill_row).apply {175 setTextViewText(R.id.autofill_row_title, title)176 setTextViewText(R.id.autofill_row_subtitle, subtitle)177 }178179 180181182183 fun datasetFor(184 context: Context,185 parsed: ParsedStructure,186 entry: VaultEntry,187 inlineSpec: android.widget.inline.InlinePresentationSpec?,188 privatePreview: Boolean,189 ): Dataset? {190 191 192 193 194 val otp = entry.totp?.let { TOTPGenerator.generate(it) }195 val otpCode = otp?.code196 val fillingOnlyOtp = parsed.identityId == null &&197 parsed.passwordId == null &&198 parsed.otpId != null &&199 otpCode != null200 val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN201 val labels = previewLabels(202 privatePreview,203 entry,204 parsed.origin?.let(::extractDomain),205 fillMode = fillMode,206 otpCode = if (fillingOnlyOtp) otpCode else null,207 secondsRemaining = otp?.remaining,208 )209 val menu = rowFor(context, labels)210 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {211 buildInline(context, labels, inlineSpec)212 } else {213 null214 }215216 val builder = Dataset.Builder()217 var filledAny = false218 fun put(id: AutofillId?, value: String?) {219 if (id == null || value.isNullOrEmpty()) return220 val av = AutofillValue.forText(value)221 if (inline != null && Build.VERSION.SDK_INT >= 30) {222 builder.setValue(id, av, menu, inline)223 } else {224 builder.setValue(id, av, menu)225 }226 filledAny = true227 }228 put(parsed.identityId, entry.username ?: entry.email)229 put(parsed.passwordId, entry.password)230 231 232 233 234 235 236 237 if (fillingOnlyOtp) put(parsed.otpId, otpCode)238 239 240 if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {241 otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->242 builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)243 }244 filledAny = true245 }246247 return if (filledAny) builder.build() else null248 }249250 251252253254255256257258259260261 fun otpDatasetFor(262 context: Context,263 parsed: ParsedStructure,264 entry: VaultEntry,265 inlineSpec: android.widget.inline.InlinePresentationSpec?,266 privatePreview: Boolean,267 ): Dataset? {268 val otpId = parsed.otpId ?: return null269 val otp = entry.totp?.let { TOTPGenerator.generate(it) } ?: return null270 val otpCode = otp.code271 val labels = previewLabels(272 privatePreview = privatePreview,273 entry = entry,274 originDomain = parsed.origin?.let(::extractDomain),275 fillMode = FillMode.OTP_FILL,276 otpCode = otpCode,277 secondsRemaining = otp.remaining,278 )279 val menu = rowFor(context, labels)280 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {281 buildInline(context, labels, inlineSpec)282 } else {283 null284 }285 val builder = Dataset.Builder()286 val av = AutofillValue.forText(otpCode)287 if (inline != null && Build.VERSION.SDK_INT >= 30) {288 builder.setValue(otpId, av, menu, inline)289 } else {290 builder.setValue(otpId, av, menu)291 }292 return builder.build()293 }294295 296297298299 fun cardDatasetFor(300 context: Context,301 parsed: ParsedStructure,302 entry: VaultEntry,303 inlineSpec: android.widget.inline.InlinePresentationSpec?,304 privatePreview: Boolean,305 ): Dataset? {306 if (!entry.isCard()) return null307 val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))308 val menu = rowFor(context, labels)309 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {310 buildInline(context, labels, inlineSpec)311 } else {312 null313 }314315 val builder = Dataset.Builder()316 var filledAny = false317 fun put(id: AutofillId?, value: String?) {318 if (id == null || value.isNullOrEmpty()) return319 val av = AutofillValue.forText(value)320 if (inline != null && Build.VERSION.SDK_INT >= 30) {321 builder.setValue(id, av, menu, inline)322 } else {323 builder.setValue(id, av, menu)324 }325 filledAny = true326 }327328 val digits = entry.cardNumber?.filter { it.isDigit() }329 put(parsed.cardNumberId, digits)330 put(parsed.cardExpMonthId, entry.expMonth)331 put(parsed.cardExpYearId, entry.expYear)332 if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {333 val yy = entry.expYear.takeLast(2)334 put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")335 }336 put(parsed.cardCvcId, entry.cvc)337 put(parsed.cardNameId, entry.cardholderName)338339 return if (filledAny) builder.build() else null340 }341342 343344345346347348349350 fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {351 val passwordId = parsed.passwordId ?: return null352 val policy = parsed.passwordPolicy353 val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH354 val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))355 val note = policy?.constraintNote(length).orEmpty()356 val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })357 return Dataset.Builder()358 .setValue(passwordId, AutofillValue.forText(password), menu)359 .build()360 }361362 363364365366367 fun identityDatasetFor(368 context: Context,369 parsed: ParsedStructure,370 identity: SavedIdentity,371 ): Dataset? {372 val targetId = when (identity.type) {373 IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId374 IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId375 } ?: return null376 val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"377 val menu = simpleRow(context, identity.value, subtitle)378 return Dataset.Builder()379 .setValue(targetId, AutofillValue.forText(identity.value), menu)380 .build()381 }382383 384385386387388389390391392393 fun searchPeachDataset(394 context: Context,395 parsed: ParsedStructure,396 inlineSpec: android.widget.inline.InlinePresentationSpec?,397 unlocked: Boolean,398 hasMatches: Boolean,399 ): Dataset {400 val labels = if (unlocked) {401 PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)402 } else {403 PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)404 }405 val menu = simpleRow(context, labels.title, labels.subtitle)406 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {407 buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed))408 } else {409 null410 }411 val builder = Dataset.Builder(menu)412 (listOfNotNull(413 parsed.identityId, parsed.passwordId, parsed.otpId,414 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,415 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,416 ) + parsed.otpDigitBoxIds).forEach { id ->417 if (inline != null && Build.VERSION.SDK_INT >= 30) {418 builder.setValue(id, null, menu, inline)419 } else {420 builder.setValue(id, null, menu)421 }422 }423 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))424 return builder.build()425 }426427 428429430431432433434435436437 fun createNewEntryDataset(438 context: Context,439 parsed: ParsedStructure,440 inlineSpec: android.widget.inline.InlinePresentationSpec?,441 unlocked: Boolean,442 origin: String?,443 ): Dataset? {444 if (!unlocked) return null445 val labels = PreviewLabels("+ Save to Peach", origin.orEmpty(), null)446 val menu = simpleRow(context, labels.title, labels.subtitle)447 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {448 buildInline(context, labels, inlineSpec, AutofillIntents.newEntryChipPendingIntent(context, parsed, origin))449 } else {450 null451 }452 val builder = Dataset.Builder(menu)453 454 455 456 (listOfNotNull(457 parsed.identityId, parsed.passwordId, parsed.otpId,458 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,459 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,460 ) + parsed.otpDigitBoxIds).forEach { id ->461 if (inline != null && Build.VERSION.SDK_INT >= 30) {462 builder.setValue(id, null, menu, inline)463 } else {464 builder.setValue(id, null, menu)465 }466 }467 return builder.build()468 }469470 471 472 @SuppressLint("RestrictedApi")473 @RequiresApi(30)474 private fun buildInline(475 context: Context,476 labels: PreviewLabels,477 spec: android.widget.inline.InlinePresentationSpec,478 chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),479 ): InlinePresentation? = try {480 val style = spec.style481 if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {482 null483 } else {484 val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }485 ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)486 val slice = InlineSuggestionUi.newContentBuilder(chipPending)487 .setTitle(labels.title)488 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }489 .setStartIcon(startIcon)490 .apply { if (labels.endIcon != null) setEndIcon(labels.endIcon) }491 .build()492 .slice493 InlinePresentation(slice, spec, false)494 }495 } catch (_: Throwable) {496 497 null498 }499500 501502503504505506507508509510511512 private object OtpCountdownDot {513 private const val SIZE_PX = 32514 private const val GREEN = 0xFF6FB07F.toInt()515 private const val AMBER = 0xFFE0A857.toInt()516 private const val RED = 0xFFD8534B.toInt()517 private val cache = LruCache<Int, Icon>(8)518519 fun icon(secondsRemaining: Int): Icon? {520 val color = when {521 secondsRemaining <= 4 -> RED522 secondsRemaining <= 9 -> AMBER523 else -> GREEN524 }525 cache.get(color)?.let { return it }526 527 528 529 530 531 val icon = runCatching {532 val bm = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)533 val canvas = Canvas(bm)534 val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }535 canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f - 2f, paint)536 Icon.createWithBitmap(bm)537 }.getOrNull() ?: return null538 cache.put(color, icon)539 return icon540 }541 }542}