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 7980818283 data class PreviewLabels(val title: String, val subtitle: String, val iconSource: String?)8485 8687888990 fun previewLabels(privatePreview: Boolean, entry: VaultEntry, originDomain: String?): PreviewLabels =91 if (privatePreview) {92 PreviewLabels("Peach", originDomain ?: "", null)93 } else {94 PreviewLabels(95 entry.title,96 entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",97 entry.title,98 )99 }100101 fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =102 RemoteViews(context.packageName, R.layout.autofill_row).apply {103 setTextViewText(R.id.autofill_row_title, labels.title)104 setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)105 }106107 fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =108 RemoteViews(context.packageName, R.layout.autofill_row).apply {109 setTextViewText(R.id.autofill_row_title, title)110 setTextViewText(R.id.autofill_row_subtitle, subtitle)111 }112113 114115116117 fun datasetFor(118 context: Context,119 parsed: ParsedStructure,120 entry: VaultEntry,121 inlineSpec: android.widget.inline.InlinePresentationSpec?,122 privatePreview: Boolean,123 ): Dataset? {124 val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))125 val menu = rowFor(context, labels)126 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {127 buildInline(context, labels, inlineSpec)128 } else {129 null130 }131132 val builder = Dataset.Builder()133 var filledAny = false134 fun put(id: AutofillId?, value: String?) {135 if (id == null || value.isNullOrEmpty()) return136 val av = AutofillValue.forText(value)137 if (inline != null && Build.VERSION.SDK_INT >= 30) {138 builder.setValue(id, av, menu, inline)139 } else {140 builder.setValue(id, av, menu)141 }142 filledAny = true143 }144 put(parsed.identityId, entry.username ?: entry.email)145 put(parsed.passwordId, entry.password)146 val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }147 put(parsed.otpId, otpCode)148 149 150 if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {151 otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->152 builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)153 }154 filledAny = true155 }156157 return if (filledAny) builder.build() else null158 }159160 161162163164 fun cardDatasetFor(165 context: Context,166 parsed: ParsedStructure,167 entry: VaultEntry,168 inlineSpec: android.widget.inline.InlinePresentationSpec?,169 privatePreview: Boolean,170 ): Dataset? {171 if (!entry.isCard()) return null172 val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))173 val menu = rowFor(context, labels)174 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {175 buildInline(context, labels, inlineSpec)176 } else {177 null178 }179180 val builder = Dataset.Builder()181 var filledAny = false182 fun put(id: AutofillId?, value: String?) {183 if (id == null || value.isNullOrEmpty()) return184 val av = AutofillValue.forText(value)185 if (inline != null && Build.VERSION.SDK_INT >= 30) {186 builder.setValue(id, av, menu, inline)187 } else {188 builder.setValue(id, av, menu)189 }190 filledAny = true191 }192193 val digits = entry.cardNumber?.filter { it.isDigit() }194 put(parsed.cardNumberId, digits)195 put(parsed.cardExpMonthId, entry.expMonth)196 put(parsed.cardExpYearId, entry.expYear)197 if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {198 val yy = entry.expYear.takeLast(2)199 put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")200 }201 put(parsed.cardCvcId, entry.cvc)202 put(parsed.cardNameId, entry.cardholderName)203204 return if (filledAny) builder.build() else null205 }206207 208209210211212213214215 fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {216 val passwordId = parsed.passwordId ?: return null217 val policy = parsed.passwordPolicy218 val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH219 val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))220 val note = policy?.constraintNote(length).orEmpty()221 val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })222 return Dataset.Builder()223 .setValue(passwordId, AutofillValue.forText(password), menu)224 .build()225 }226227 228229230231232 fun identityDatasetFor(233 context: Context,234 parsed: ParsedStructure,235 identity: SavedIdentity,236 ): Dataset? {237 val targetId = when (identity.type) {238 IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId239 IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId240 } ?: return null241 val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"242 val menu = simpleRow(context, identity.value, subtitle)243 return Dataset.Builder()244 .setValue(targetId, AutofillValue.forText(identity.value), menu)245 .build()246 }247248 249 250 @SuppressLint("RestrictedApi")251 @RequiresApi(30)252 private fun buildInline(253 context: Context,254 labels: PreviewLabels,255 spec: android.widget.inline.InlinePresentationSpec,256 ): InlinePresentation? = try {257 val style = spec.style258 if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {259 null260 } else {261 val pending = AutofillIntents.noopChipIntent(context)262 val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }263 ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)264 val slice = InlineSuggestionUi.newContentBuilder(pending)265 .setTitle(labels.title)266 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }267 .setStartIcon(startIcon)268 .build()269 .slice270 InlinePresentation(slice, spec, false)271 }272 } catch (_: Throwable) {273 274 null275 }276}