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 49505152535455 fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {56 val byDomain = matchDomains(entries, parsed.originDomains.mapNotNull(::extractDomain).toSet()).hits57 val byPackage = nativePackageMatches(entries, parsed)58 return MatchResult((byDomain + byPackage).distinctBy { it.id }, emptyList())59 }6061 6263646566676869 private fun nativePackageMatches(entries: List<VaultEntry>, parsed: ParsedStructure): List<VaultEntry> {70 val packageName = parsed.packageName?.takeIf { it.isNotBlank() } ?: return emptyList()71 if (parsed.webDomain != null) return emptyList()72 return entries.filter { entry ->73 entry.url == packageName || entry.additionalUrls.orEmpty().any { it == packageName }74 }75 }7677 private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =78 MatchResult(79 hits = entries.filter { entry ->80 domains.any { domain -> entry.matchesOriginDomain(domain) }81 },82 rest = emptyList(),83 )8485 86 internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =87 code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }8889 private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =90 sequenceOf(url)91 .plus(additionalUrls.orEmpty().asSequence())92 .filterNotNull()93 .mapNotNull(::extractDomain)94 .any { it == originDomain }9596 979899100101 enum class FillMode { LOGIN, OTP_FILL }102103 data class PreviewLabels(104 val title: String,105 val subtitle: String,106 val iconSource: String?,107 val fillMode: FillMode = FillMode.LOGIN,108 val endIcon: Icon? = null,109 )110111 112113114115116 117118119120121122123 internal fun chunkOtpForDisplay(code: String): String {124 if (code.length != 6 || !code.all { it.isDigit() }) return code125 return code.chunked(3).joinToString(" ")126 }127128 129130131132133134135136137 fun previewLabels(138 privatePreview: Boolean,139 entry: VaultEntry,140 originDomain: String?,141 fillMode: FillMode = FillMode.LOGIN,142 otpCode: String? = null,143 secondsRemaining: Int? = null,144 ): PreviewLabels = when {145 privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)146 fillMode == FillMode.OTP_FILL -> {147 val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" }148 149 150 151 152 153 154 155 156 157 val subtitle = when {158 secondsRemaining == null -> "2FA"159 secondsRemaining <= 0 -> "2FA · expired"160 else -> "2FA · ${secondsRemaining}s"161 }162 val endIcon = secondsRemaining?.let { OtpCountdownDot.icon(it) }163 PreviewLabels(164 title = chunkOtpForDisplay(code),165 subtitle = subtitle,166 iconSource = entry.title,167 fillMode = FillMode.OTP_FILL,168 endIcon = endIcon,169 )170 }171 else -> PreviewLabels(172 entry.title,173 entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",174 entry.title,175 FillMode.LOGIN,176 )177 }178179 fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =180 RemoteViews(context.packageName, R.layout.autofill_row).apply {181 setTextViewText(R.id.autofill_row_title, labels.title)182 setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)183 }184185 fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =186 RemoteViews(context.packageName, R.layout.autofill_row).apply {187 setTextViewText(R.id.autofill_row_title, title)188 setTextViewText(R.id.autofill_row_subtitle, subtitle)189 }190191 192193194195 fun datasetFor(196 context: Context,197 parsed: ParsedStructure,198 entry: VaultEntry,199 inlineSpec: android.widget.inline.InlinePresentationSpec?,200 privatePreview: Boolean,201 ): Dataset? {202 203 204 205 206 val otp = entry.totp?.let { TOTPGenerator.generate(it) }207 val otpCode = otp?.code208 val fillingOnlyOtp = parsed.identityId == null &&209 parsed.passwordId == null &&210 parsed.otpId != null &&211 otpCode != null212 val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN213 val labels = previewLabels(214 privatePreview,215 entry,216 parsed.origin?.let(::extractDomain),217 fillMode = fillMode,218 otpCode = if (fillingOnlyOtp) otpCode else null,219 secondsRemaining = otp?.remaining,220 )221 val menu = rowFor(context, labels)222 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {223 buildInline(context, labels, inlineSpec)224 } else {225 null226 }227228 val builder = Dataset.Builder()229 var filledAny = false230 fun put(id: AutofillId?, value: String?) {231 if (id == null || value.isNullOrEmpty()) return232 val av = AutofillValue.forText(value)233 if (inline != null && Build.VERSION.SDK_INT >= 30) {234 builder.setValue(id, av, menu, inline)235 } else {236 builder.setValue(id, av, menu)237 }238 filledAny = true239 }240 put(parsed.identityId, entry.username ?: entry.email)241 put(parsed.passwordId, entry.password)242 243 244 245 246 247 248 249 if (fillingOnlyOtp) put(parsed.otpId, otpCode)250 251 252 if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {253 otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->254 builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)255 }256 filledAny = true257 }258259 return if (filledAny) builder.build() else null260 }261262 263264265266267268269270271272273 fun otpDatasetFor(274 context: Context,275 parsed: ParsedStructure,276 entry: VaultEntry,277 inlineSpec: android.widget.inline.InlinePresentationSpec?,278 privatePreview: Boolean,279 ): Dataset? {280 val otpId = parsed.otpId ?: return null281 val otp = entry.totp?.let { TOTPGenerator.generate(it) } ?: return null282 val otpCode = otp.code283 val labels = previewLabels(284 privatePreview = privatePreview,285 entry = entry,286 originDomain = parsed.origin?.let(::extractDomain),287 fillMode = FillMode.OTP_FILL,288 otpCode = otpCode,289 secondsRemaining = otp.remaining,290 )291 val menu = rowFor(context, labels)292 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {293 buildInline(context, labels, inlineSpec)294 } else {295 null296 }297 val builder = Dataset.Builder()298 val av = AutofillValue.forText(otpCode)299 if (inline != null && Build.VERSION.SDK_INT >= 30) {300 builder.setValue(otpId, av, menu, inline)301 } else {302 builder.setValue(otpId, av, menu)303 }304 return builder.build()305 }306307 308309310311 fun cardDatasetFor(312 context: Context,313 parsed: ParsedStructure,314 entry: VaultEntry,315 inlineSpec: android.widget.inline.InlinePresentationSpec?,316 privatePreview: Boolean,317 ): Dataset? {318 if (!entry.isCard()) return null319 val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))320 val menu = rowFor(context, labels)321 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {322 buildInline(context, labels, inlineSpec)323 } else {324 null325 }326327 val builder = Dataset.Builder()328 var filledAny = false329 fun put(id: AutofillId?, value: String?) {330 if (id == null || value.isNullOrEmpty()) return331 val av = AutofillValue.forText(value)332 if (inline != null && Build.VERSION.SDK_INT >= 30) {333 builder.setValue(id, av, menu, inline)334 } else {335 builder.setValue(id, av, menu)336 }337 filledAny = true338 }339340 val digits = entry.cardNumber?.filter { it.isDigit() }341 put(parsed.cardNumberId, digits)342 put(parsed.cardExpMonthId, entry.expMonth)343 put(parsed.cardExpYearId, entry.expYear)344 if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {345 val yy = entry.expYear.takeLast(2)346 put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")347 }348 put(parsed.cardCvcId, entry.cvc)349 put(parsed.cardNameId, entry.cardholderName)350351 return if (filledAny) builder.build() else null352 }353354 355356357358359360361362 fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {363 val passwordId = parsed.passwordId ?: return null364 val policy = parsed.passwordPolicy365 val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH366 val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))367 val note = policy?.constraintNote(length).orEmpty()368 val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })369 return Dataset.Builder()370 .setValue(passwordId, AutofillValue.forText(password), menu)371 .build()372 }373374 375376377378379 fun identityDatasetFor(380 context: Context,381 parsed: ParsedStructure,382 identity: SavedIdentity,383 ): Dataset? {384 val targetId = when (identity.type) {385 IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId386 IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId387 } ?: return null388 val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"389 val menu = simpleRow(context, identity.value, subtitle)390 return Dataset.Builder()391 .setValue(targetId, AutofillValue.forText(identity.value), menu)392 .build()393 }394395 396397398399400401402403404405 fun searchPeachDataset(406 context: Context,407 parsed: ParsedStructure,408 inlineSpec: android.widget.inline.InlinePresentationSpec?,409 unlocked: Boolean,410 hasMatches: Boolean,411 ): Dataset {412 val labels = if (unlocked) {413 PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)414 } else {415 PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)416 }417 val menu = simpleRow(context, labels.title, labels.subtitle)418 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {419 buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed))420 } else {421 null422 }423 val builder = Dataset.Builder(menu)424 (listOfNotNull(425 parsed.identityId, parsed.passwordId, parsed.otpId,426 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,427 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,428 ) + parsed.otpDigitBoxIds).forEach { id ->429 if (inline != null && Build.VERSION.SDK_INT >= 30) {430 builder.setValue(id, null, menu, inline)431 } else {432 builder.setValue(id, null, menu)433 }434 }435 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))436 return builder.build()437 }438439 440 441 @SuppressLint("RestrictedApi")442 @RequiresApi(30)443 private fun buildInline(444 context: Context,445 labels: PreviewLabels,446 spec: android.widget.inline.InlinePresentationSpec,447 chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),448 ): InlinePresentation? = try {449 val style = spec.style450 if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {451 null452 } else {453 val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }454 ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)455 val slice = InlineSuggestionUi.newContentBuilder(chipPending)456 .setTitle(labels.title)457 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }458 .setStartIcon(startIcon)459 .apply { if (labels.endIcon != null) setEndIcon(labels.endIcon) }460 .build()461 .slice462 InlinePresentation(slice, spec, false)463 }464 } catch (_: Throwable) {465 466 null467 }468469 470471472473474475476477478479480481 private object OtpCountdownDot {482 private const val SIZE_PX = 32483 private const val GREEN = 0xFF6FB07F.toInt()484 private const val AMBER = 0xFFE0A857.toInt()485 private const val RED = 0xFFD8534B.toInt()486 private val cache = LruCache<Int, Icon>(8)487488 fun icon(secondsRemaining: Int): Icon? {489 val color = when {490 secondsRemaining <= 4 -> RED491 secondsRemaining <= 9 -> AMBER492 else -> GREEN493 }494 cache.get(color)?.let { return it }495 496 497 498 499 500 val icon = runCatching {501 val bm = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)502 val canvas = Canvas(bm)503 val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }504 canvas.drawCircle(SIZE_PX / 2f, SIZE_PX / 2f, SIZE_PX / 2f - 2f, paint)505 Icon.createWithBitmap(bm)506 }.getOrNull() ?: return null507 cache.put(color, icon)508 return icon509 }510 }511}