1package com.peachpasswords.autofill23import android.annotation.SuppressLint4import android.content.Context5import android.os.Build6import android.service.autofill.Dataset7import android.service.autofill.InlinePresentation8import android.view.autofill.AutofillId9import android.view.autofill.AutofillValue10import android.widget.RemoteViews11import androidx.annotation.RequiresApi12import androidx.autofill.inline.v1.InlineSuggestionUi13import androidx.autofill.inline.UiVersions14import com.peachpasswords.R15import com.peachpasswords.shared.extractDomain16import com.peachpasswords.vault.PasswordGenerator17import com.peachpasswords.vault.TOTPGenerator18import com.peachpasswords.vault.VaultEntry19import com.peachpasswords.vault.isCard20212223242526object AutofillResponseBuilder {2728 29303132 data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {33 val all: List<VaultEntry> get() = hits + rest34 }3536 fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {37 val originDomain = origin?.let(::extractDomain)38 ?: return MatchResult(emptyList(), emptyList())39 return matchDomains(entries, setOf(originDomain))40 }4142 4344454647 fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {48 val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()49 val byDomain = matchDomains(entries, domains).hits50 val appTitle = parsed.appTitle?.trim()51 val byTitle = if (appTitle.isNullOrBlank()) {52 emptyList()53 } else {54 entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) }55 }56 return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList())57 }5859 private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =60 MatchResult(61 hits = entries.filter { entry ->62 domains.any { domain -> entry.matchesOriginDomain(domain) }63 },64 rest = emptyList(),65 )6667 68 internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =69 code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }7071 private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =72 sequenceOf(url)73 .plus(additionalUrls.orEmpty().asSequence())74 .filterNotNull()75 .mapNotNull(::extractDomain)76 .any { it == originDomain }7778 fun menuRow(context: Context, entry: VaultEntry): RemoteViews {79 val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""80 return RemoteViews(context.packageName, R.layout.autofill_row).apply {81 setTextViewText(R.id.autofill_row_title, entry.title)82 setTextViewText(R.id.autofill_row_subtitle, subtitle)83 }84 }8586 fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =87 RemoteViews(context.packageName, R.layout.autofill_row).apply {88 setTextViewText(R.id.autofill_row_title, title)89 setTextViewText(R.id.autofill_row_subtitle, subtitle)90 }9192 93949596 fun datasetFor(97 context: Context,98 parsed: ParsedStructure,99 entry: VaultEntry,100 inlineSpec: android.widget.inline.InlinePresentationSpec?,101 ): Dataset? {102 val menu = menuRow(context, entry)103 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {104 buildInline(context, entry, inlineSpec)105 } else {106 null107 }108109 val builder = Dataset.Builder()110 var filledAny = false111 fun put(id: AutofillId?, value: String?) {112 if (id == null || value.isNullOrEmpty()) return113 val av = AutofillValue.forText(value)114 if (inline != null && Build.VERSION.SDK_INT >= 30) {115 builder.setValue(id, av, menu, inline)116 } else {117 builder.setValue(id, av, menu)118 }119 filledAny = true120 }121 put(parsed.identityId, entry.username ?: entry.email)122 put(parsed.passwordId, entry.password)123 val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }124 put(parsed.otpId, otpCode)125 126 127 if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {128 otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->129 builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)130 }131 filledAny = true132 }133134 return if (filledAny) builder.build() else null135 }136137 138139140141 fun cardDatasetFor(142 context: Context,143 parsed: ParsedStructure,144 entry: VaultEntry,145 inlineSpec: android.widget.inline.InlinePresentationSpec?,146 ): Dataset? {147 if (!entry.isCard()) return null148 val menu = menuRow(context, entry)149 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {150 buildInline(context, entry, inlineSpec)151 } else {152 null153 }154155 val builder = Dataset.Builder()156 var filledAny = false157 fun put(id: AutofillId?, value: String?) {158 if (id == null || value.isNullOrEmpty()) return159 val av = AutofillValue.forText(value)160 if (inline != null && Build.VERSION.SDK_INT >= 30) {161 builder.setValue(id, av, menu, inline)162 } else {163 builder.setValue(id, av, menu)164 }165 filledAny = true166 }167168 val digits = entry.cardNumber?.filter { it.isDigit() }169 put(parsed.cardNumberId, digits)170 put(parsed.cardExpMonthId, entry.expMonth)171 put(parsed.cardExpYearId, entry.expYear)172 if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {173 val yy = entry.expYear.takeLast(2)174 put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")175 }176 put(parsed.cardCvcId, entry.cvc)177 put(parsed.cardNameId, entry.cardholderName)178179 return if (filledAny) builder.build() else null180 }181182 183184185186187188189190 fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {191 val passwordId = parsed.passwordId ?: return null192 val policy = parsed.passwordPolicy193 val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH194 val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))195 val note = policy?.constraintNote(length).orEmpty()196 val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })197 return Dataset.Builder()198 .setValue(passwordId, AutofillValue.forText(password), menu)199 .build()200 }201202 203204205206207 fun identityDatasetFor(208 context: Context,209 parsed: ParsedStructure,210 identity: SavedIdentity,211 ): Dataset? {212 val targetId = when (identity.type) {213 IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId214 IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId215 } ?: return null216 val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"217 val menu = simpleRow(context, identity.value, subtitle)218 return Dataset.Builder()219 .setValue(targetId, AutofillValue.forText(identity.value), menu)220 .build()221 }222223 224 225 @SuppressLint("RestrictedApi")226 @RequiresApi(30)227 private fun buildInline(228 context: Context,229 entry: VaultEntry,230 spec: android.widget.inline.InlinePresentationSpec,231 ): InlinePresentation? = try {232 val style = spec.style233 if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {234 null235 } else {236 val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""237 val pending = AutofillIntents.noopChipIntent(context)238 val slice = InlineSuggestionUi.newContentBuilder(pending)239 .setTitle(entry.title)240 .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) }241 .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark))242 .build()243 .slice244 InlinePresentation(slice, spec, false)245 }246 } catch (_: Throwable) {247 248 null249 }250}