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 private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =68 sequenceOf(url)69 .plus(additionalUrls.orEmpty().asSequence())70 .filterNotNull()71 .mapNotNull(::extractDomain)72 .any { it == originDomain }7374 fun menuRow(context: Context, entry: VaultEntry): RemoteViews {75 val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""76 return RemoteViews(context.packageName, R.layout.autofill_row).apply {77 setTextViewText(R.id.autofill_row_title, entry.title)78 setTextViewText(R.id.autofill_row_subtitle, subtitle)79 }80 }8182 fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =83 RemoteViews(context.packageName, R.layout.autofill_row).apply {84 setTextViewText(R.id.autofill_row_title, title)85 setTextViewText(R.id.autofill_row_subtitle, subtitle)86 }8788 89909192 fun datasetFor(93 context: Context,94 parsed: ParsedStructure,95 entry: VaultEntry,96 inlineSpec: android.widget.inline.InlinePresentationSpec?,97 ): Dataset? {98 val menu = menuRow(context, entry)99 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {100 buildInline(context, entry, inlineSpec)101 } else {102 null103 }104105 val builder = Dataset.Builder()106 var filledAny = false107 fun put(id: AutofillId?, value: String?) {108 if (id == null || value.isNullOrEmpty()) return109 val av = AutofillValue.forText(value)110 if (inline != null && Build.VERSION.SDK_INT >= 30) {111 builder.setValue(id, av, menu, inline)112 } else {113 builder.setValue(id, av, menu)114 }115 filledAny = true116 }117 put(parsed.identityId, entry.username ?: entry.email)118 put(parsed.passwordId, entry.password)119 put(parsed.otpId, entry.totp?.let { TOTPGenerator.generateCode(it) })120121 return if (filledAny) builder.build() else null122 }123124 125126127128 fun cardDatasetFor(129 context: Context,130 parsed: ParsedStructure,131 entry: VaultEntry,132 inlineSpec: android.widget.inline.InlinePresentationSpec?,133 ): Dataset? {134 if (!entry.isCard()) return null135 val menu = menuRow(context, entry)136 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {137 buildInline(context, entry, inlineSpec)138 } else {139 null140 }141142 val builder = Dataset.Builder()143 var filledAny = false144 fun put(id: AutofillId?, value: String?) {145 if (id == null || value.isNullOrEmpty()) return146 val av = AutofillValue.forText(value)147 if (inline != null && Build.VERSION.SDK_INT >= 30) {148 builder.setValue(id, av, menu, inline)149 } else {150 builder.setValue(id, av, menu)151 }152 filledAny = true153 }154155 val digits = entry.cardNumber?.filter { it.isDigit() }156 put(parsed.cardNumberId, digits)157 put(parsed.cardExpMonthId, entry.expMonth)158 put(parsed.cardExpYearId, entry.expYear)159 if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {160 val yy = entry.expYear.takeLast(2)161 put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")162 }163 put(parsed.cardCvcId, entry.cvc)164 put(parsed.cardNameId, entry.cardholderName)165166 return if (filledAny) builder.build() else null167 }168169 170171172173174175176177 fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {178 val passwordId = parsed.passwordId ?: return null179 val policy = parsed.passwordPolicy180 val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH181 val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))182 val note = policy?.constraintNote(length).orEmpty()183 val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })184 return Dataset.Builder()185 .setValue(passwordId, AutofillValue.forText(password), menu)186 .build()187 }188189 190191192193194 fun identityDatasetFor(195 context: Context,196 parsed: ParsedStructure,197 identity: SavedIdentity,198 ): Dataset? {199 val targetId = when (identity.type) {200 IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId201 IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId202 } ?: return null203 val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"204 val menu = simpleRow(context, identity.value, subtitle)205 return Dataset.Builder()206 .setValue(targetId, AutofillValue.forText(identity.value), menu)207 .build()208 }209210 211 212 @SuppressLint("RestrictedApi")213 @RequiresApi(30)214 private fun buildInline(215 context: Context,216 entry: VaultEntry,217 spec: android.widget.inline.InlinePresentationSpec,218 ): InlinePresentation? = try {219 val style = spec.style220 if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {221 null222 } else {223 val subtitle = entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: ""224 val pending = AutofillIntents.noopChipIntent(context)225 val slice = InlineSuggestionUi.newContentBuilder(pending)226 .setTitle(entry.title)227 .apply { if (subtitle.isNotBlank()) setSubtitle(subtitle) }228 .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark))229 .build()230 .slice231 InlinePresentation(slice, spec, false)232 }233 } catch (_: Throwable) {234 235 null236 }237}