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 androidx.autofill.inline.v1.InlineSuggestionUi14import androidx.autofill.inline.UiVersions15import com.peachpasswords.R16import com.peachpasswords.shared.extractDomain17import com.peachpasswords.vault.PasswordGenerator18import com.peachpasswords.vault.TOTPGenerator19import com.peachpasswords.vault.VaultEntry20import com.peachpasswords.vault.isCard21222324252627object AutofillResponseBuilder {2829 30313233 data class MatchResult(val hits: List<VaultEntry>, val rest: List<VaultEntry>) {34 val all: List<VaultEntry> get() = hits + rest35 }3637 fun matchEntries(entries: List<VaultEntry>, origin: String?): MatchResult {38 val originDomain = origin?.let(::extractDomain)39 ?: return MatchResult(emptyList(), emptyList())40 return matchDomains(entries, setOf(originDomain))41 }4243 4445464748 fun matchEntries(entries: List<VaultEntry>, parsed: ParsedStructure): MatchResult {49 val domains = parsed.originDomains.mapNotNull(::extractDomain).toSet()50 val byDomain = matchDomains(entries, domains).hits51 val appTitle = parsed.appTitle?.trim()52 val byTitle = if (appTitle.isNullOrBlank()) {53 emptyList()54 } else {55 entries.filter { it.title.trim().equals(appTitle, ignoreCase = true) }56 }57 return MatchResult((byDomain + byTitle).distinctBy { it.id }, emptyList())58 }5960 private fun matchDomains(entries: List<VaultEntry>, domains: Set<String>): MatchResult =61 MatchResult(62 hits = entries.filter { entry ->63 domains.any { domain -> entry.matchesOriginDomain(domain) }64 },65 rest = emptyList(),66 )6768 69 internal fun otpDigitsFor(boxCount: Int, code: String): List<String> =70 code.filter { !it.isWhitespace() }.take(boxCount).map { it.toString() }7172 private fun VaultEntry.matchesOriginDomain(originDomain: String): Boolean =73 sequenceOf(url)74 .plus(additionalUrls.orEmpty().asSequence())75 .filterNotNull()76 .mapNotNull(::extractDomain)77 .any { it == originDomain }7879 8081828384 data class PreviewLabels(val title: String, val subtitle: String, val iconSource: String?)8586 8788899091 fun previewLabels(privatePreview: Boolean, entry: VaultEntry, originDomain: String?): PreviewLabels =92 if (privatePreview) {93 PreviewLabels("Peach", originDomain ?: "", null)94 } else {95 PreviewLabels(96 entry.title,97 entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",98 entry.title,99 )100 }101102 fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =103 RemoteViews(context.packageName, R.layout.autofill_row).apply {104 setTextViewText(R.id.autofill_row_title, labels.title)105 setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)106 }107108 fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =109 RemoteViews(context.packageName, R.layout.autofill_row).apply {110 setTextViewText(R.id.autofill_row_title, title)111 setTextViewText(R.id.autofill_row_subtitle, subtitle)112 }113114 115116117118 fun datasetFor(119 context: Context,120 parsed: ParsedStructure,121 entry: VaultEntry,122 inlineSpec: android.widget.inline.InlinePresentationSpec?,123 privatePreview: Boolean,124 ): Dataset? {125 val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))126 val menu = rowFor(context, labels)127 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {128 buildInline(context, labels, inlineSpec)129 } else {130 null131 }132133 val builder = Dataset.Builder()134 var filledAny = false135 fun put(id: AutofillId?, value: String?) {136 if (id == null || value.isNullOrEmpty()) return137 val av = AutofillValue.forText(value)138 if (inline != null && Build.VERSION.SDK_INT >= 30) {139 builder.setValue(id, av, menu, inline)140 } else {141 builder.setValue(id, av, menu)142 }143 filledAny = true144 }145 put(parsed.identityId, entry.username ?: entry.email)146 put(parsed.passwordId, entry.password)147 val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }148 put(parsed.otpId, otpCode)149 150 151 if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {152 otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->153 builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)154 }155 filledAny = true156 }157158 return if (filledAny) builder.build() else null159 }160161 162163164165 fun cardDatasetFor(166 context: Context,167 parsed: ParsedStructure,168 entry: VaultEntry,169 inlineSpec: android.widget.inline.InlinePresentationSpec?,170 privatePreview: Boolean,171 ): Dataset? {172 if (!entry.isCard()) return null173 val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))174 val menu = rowFor(context, labels)175 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {176 buildInline(context, labels, inlineSpec)177 } else {178 null179 }180181 val builder = Dataset.Builder()182 var filledAny = false183 fun put(id: AutofillId?, value: String?) {184 if (id == null || value.isNullOrEmpty()) return185 val av = AutofillValue.forText(value)186 if (inline != null && Build.VERSION.SDK_INT >= 30) {187 builder.setValue(id, av, menu, inline)188 } else {189 builder.setValue(id, av, menu)190 }191 filledAny = true192 }193194 val digits = entry.cardNumber?.filter { it.isDigit() }195 put(parsed.cardNumberId, digits)196 put(parsed.cardExpMonthId, entry.expMonth)197 put(parsed.cardExpYearId, entry.expYear)198 if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {199 val yy = entry.expYear.takeLast(2)200 put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")201 }202 put(parsed.cardCvcId, entry.cvc)203 put(parsed.cardNameId, entry.cardholderName)204205 return if (filledAny) builder.build() else null206 }207208 209210211212213214215216 fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {217 val passwordId = parsed.passwordId ?: return null218 val policy = parsed.passwordPolicy219 val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH220 val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))221 val note = policy?.constraintNote(length).orEmpty()222 val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })223 return Dataset.Builder()224 .setValue(passwordId, AutofillValue.forText(password), menu)225 .build()226 }227228 229230231232233 fun identityDatasetFor(234 context: Context,235 parsed: ParsedStructure,236 identity: SavedIdentity,237 ): Dataset? {238 val targetId = when (identity.type) {239 IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId240 IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId241 } ?: return null242 val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"243 val menu = simpleRow(context, identity.value, subtitle)244 return Dataset.Builder()245 .setValue(targetId, AutofillValue.forText(identity.value), menu)246 .build()247 }248249 250251252253254255256257258259 fun searchPeachDataset(260 context: Context,261 parsed: ParsedStructure,262 inlineSpec: android.widget.inline.InlinePresentationSpec?,263 unlocked: Boolean,264 hasMatches: Boolean,265 ): Dataset {266 val labels = if (unlocked) {267 PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)268 } else {269 PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)270 }271 val menu = simpleRow(context, labels.title, labels.subtitle)272 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {273 buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed))274 } else {275 null276 }277 val builder = Dataset.Builder(menu)278 (listOfNotNull(279 parsed.identityId, parsed.passwordId, parsed.otpId,280 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,281 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,282 ) + parsed.otpDigitBoxIds).forEach { id ->283 if (inline != null && Build.VERSION.SDK_INT >= 30) {284 builder.setValue(id, null, menu, inline)285 } else {286 builder.setValue(id, null, menu)287 }288 }289 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))290 return builder.build()291 }292293 294295296297298299300301302303 fun createNewEntryDataset(304 context: Context,305 parsed: ParsedStructure,306 inlineSpec: android.widget.inline.InlinePresentationSpec?,307 unlocked: Boolean,308 origin: String?,309 ): Dataset? {310 if (!unlocked) return null311 val labels = PreviewLabels("+ Save to Peach", origin.orEmpty(), null)312 val menu = simpleRow(context, labels.title, labels.subtitle)313 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {314 buildInline(context, labels, inlineSpec, AutofillIntents.newEntryChipPendingIntent(context, parsed, origin))315 } else {316 null317 }318 val builder = Dataset.Builder(menu)319 320 321 322 (listOfNotNull(323 parsed.identityId, parsed.passwordId, parsed.otpId,324 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,325 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,326 ) + parsed.otpDigitBoxIds).forEach { id ->327 if (inline != null && Build.VERSION.SDK_INT >= 30) {328 builder.setValue(id, null, menu, inline)329 } else {330 builder.setValue(id, null, menu)331 }332 }333 return builder.build()334 }335336 337 338 @SuppressLint("RestrictedApi")339 @RequiresApi(30)340 private fun buildInline(341 context: Context,342 labels: PreviewLabels,343 spec: android.widget.inline.InlinePresentationSpec,344 chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),345 ): InlinePresentation? = try {346 val style = spec.style347 if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {348 null349 } else {350 val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }351 ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)352 val slice = InlineSuggestionUi.newContentBuilder(chipPending)353 .setTitle(labels.title)354 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }355 .setStartIcon(startIcon)356 .build()357 .slice358 InlinePresentation(slice, spec, false)359 }360 } catch (_: Throwable) {361 362 null363 }364}