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 428 429 @SuppressLint("RestrictedApi")430 @RequiresApi(30)431 private fun buildInline(432 context: Context,433 labels: PreviewLabels,434 spec: android.widget.inline.InlinePresentationSpec,435 chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),436 ): InlinePresentation? = try {437 val style = spec.style438 if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {439 null440 } else {441 val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }442 ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)443 val slice = InlineSuggestionUi.newContentBuilder(chipPending)444 .setTitle(labels.title)445 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }446 .setStartIcon(startIcon)447 .apply { if (labels.endIcon != null) setEndIcon(labels.endIcon) }448 .build()449 .slice450 InlinePresentation(slice, spec, false)451 }452 } catch (_: Throwable) {453 454 null455 }456457 458459460461462463464465466467468469 private object OtpCountdownDot {470 private const val SIZE_PX = 32471 private const val GREEN = 0xFF6FB07F.toInt()472 private const val AMBER = 0xFFE0A857.toInt()473 private const val RED = 0xFFD8534B.toInt()474 private val cache = LruCache<Int, Icon>(8)475476 fun icon(secondsRemaining: Int): Icon? {477 val color = when {478 secondsRemaining <= 4 -> RED479 secondsRemaining <= 9 -> AMBER480 else -> GREEN481 }482 cache.get(color)?.let { return it }483 484 485 486 487 488 val icon = runCatching {489 val bm = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)490 val canvas = Canvas(bm)491 val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }492 canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f - 2f, paint)493 Icon.createWithBitmap(bm)494 }.getOrNull() ?: return null495 cache.put(color, icon)496 return icon497 }498 }499}