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 enum class FillMode { LOGIN, OTP_FILL }8586 data class PreviewLabels(87 val title: String,88 val subtitle: String,89 val iconSource: String?,90 val fillMode: FillMode = FillMode.LOGIN,91 )9293 9495969798 99100101102103104105 internal fun chunkOtpForDisplay(code: String): String {106 if (code.length != 6 || !code.all { it.isDigit() }) return code107 return code.chunked(3).joinToString(" ")108 }109110 111112113114115116117118119 fun previewLabels(120 privatePreview: Boolean,121 entry: VaultEntry,122 originDomain: String?,123 fillMode: FillMode = FillMode.LOGIN,124 otpCode: String? = null,125 ): PreviewLabels = when {126 privatePreview -> PreviewLabels("Peach", originDomain ?: "", null, fillMode)127 fillMode == FillMode.OTP_FILL -> {128 val code = requireNotNull(otpCode) { "otpCode required for OTP_FILL mode" }129 130 131 132 133 134 PreviewLabels(135 title = chunkOtpForDisplay(code),136 subtitle = "2FA",137 iconSource = entry.title,138 fillMode = FillMode.OTP_FILL,139 )140 }141 else -> PreviewLabels(142 entry.title,143 entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",144 entry.title,145 FillMode.LOGIN,146 )147 }148149 fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =150 RemoteViews(context.packageName, R.layout.autofill_row).apply {151 setTextViewText(R.id.autofill_row_title, labels.title)152 setTextViewText(R.id.autofill_row_subtitle, labels.subtitle)153 }154155 fun simpleRow(context: Context, title: String, subtitle: String): RemoteViews =156 RemoteViews(context.packageName, R.layout.autofill_row).apply {157 setTextViewText(R.id.autofill_row_title, title)158 setTextViewText(R.id.autofill_row_subtitle, subtitle)159 }160161 162163164165 fun datasetFor(166 context: Context,167 parsed: ParsedStructure,168 entry: VaultEntry,169 inlineSpec: android.widget.inline.InlinePresentationSpec?,170 privatePreview: Boolean,171 ): Dataset? {172 173 174 175 176 val otpCode = entry.totp?.let { TOTPGenerator.generateCode(it) }177 val fillingOnlyOtp = parsed.identityId == null &&178 parsed.passwordId == null &&179 parsed.otpId != null &&180 otpCode != null181 val fillMode = if (fillingOnlyOtp) FillMode.OTP_FILL else FillMode.LOGIN182 val labels = previewLabels(183 privatePreview,184 entry,185 parsed.origin?.let(::extractDomain),186 fillMode = fillMode,187 otpCode = if (fillingOnlyOtp) otpCode else null,188 )189 val menu = rowFor(context, labels)190 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {191 buildInline(context, labels, inlineSpec)192 } else {193 null194 }195196 val builder = Dataset.Builder()197 var filledAny = false198 fun put(id: AutofillId?, value: String?) {199 if (id == null || value.isNullOrEmpty()) return200 val av = AutofillValue.forText(value)201 if (inline != null && Build.VERSION.SDK_INT >= 30) {202 builder.setValue(id, av, menu, inline)203 } else {204 builder.setValue(id, av, menu)205 }206 filledAny = true207 }208 put(parsed.identityId, entry.username ?: entry.email)209 put(parsed.passwordId, entry.password)210 211 212 put(parsed.otpId, otpCode)213 214 215 if (parsed.otpId == null && otpCode != null && parsed.otpDigitBoxIds.isNotEmpty()) {216 otpDigitsFor(parsed.otpDigitBoxIds.size, otpCode).forEachIndexed { index, digit ->217 builder.setValue(parsed.otpDigitBoxIds[index], AutofillValue.forText(digit), menu)218 }219 filledAny = true220 }221222 return if (filledAny) builder.build() else null223 }224225 226227228229 fun cardDatasetFor(230 context: Context,231 parsed: ParsedStructure,232 entry: VaultEntry,233 inlineSpec: android.widget.inline.InlinePresentationSpec?,234 privatePreview: Boolean,235 ): Dataset? {236 if (!entry.isCard()) return null237 val labels = previewLabels(privatePreview, entry, parsed.origin?.let(::extractDomain))238 val menu = rowFor(context, labels)239 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {240 buildInline(context, labels, inlineSpec)241 } else {242 null243 }244245 val builder = Dataset.Builder()246 var filledAny = false247 fun put(id: AutofillId?, value: String?) {248 if (id == null || value.isNullOrEmpty()) return249 val av = AutofillValue.forText(value)250 if (inline != null && Build.VERSION.SDK_INT >= 30) {251 builder.setValue(id, av, menu, inline)252 } else {253 builder.setValue(id, av, menu)254 }255 filledAny = true256 }257258 val digits = entry.cardNumber?.filter { it.isDigit() }259 put(parsed.cardNumberId, digits)260 put(parsed.cardExpMonthId, entry.expMonth)261 put(parsed.cardExpYearId, entry.expYear)262 if (parsed.cardExpDateId != null && !entry.expMonth.isNullOrEmpty() && !entry.expYear.isNullOrEmpty()) {263 val yy = entry.expYear.takeLast(2)264 put(parsed.cardExpDateId, "${entry.expMonth.padStart(2, '0')}/$yy")265 }266 put(parsed.cardCvcId, entry.cvc)267 put(parsed.cardNameId, entry.cardholderName)268269 return if (filledAny) builder.build() else null270 }271272 273274275276277278279280 fun generateDatasetFor(context: Context, parsed: ParsedStructure): Dataset? {281 val passwordId = parsed.passwordId ?: return null282 val policy = parsed.passwordPolicy283 val length = policy?.generationLength() ?: PasswordPolicy.DEFAULT_LENGTH284 val password = PasswordGenerator.generate(PasswordGenerator.Options(length = length))285 val note = policy?.constraintNote(length).orEmpty()286 val menu = simpleRow(context, "Generate strong password", note.ifEmpty { "Peach" })287 return Dataset.Builder()288 .setValue(passwordId, AutofillValue.forText(password), menu)289 .build()290 }291292 293294295296297 fun identityDatasetFor(298 context: Context,299 parsed: ParsedStructure,300 identity: SavedIdentity,301 ): Dataset? {302 val targetId = when (identity.type) {303 IdentityType.EMAIL -> parsed.emailId ?: parsed.usernameId304 IdentityType.USERNAME -> parsed.usernameId ?: parsed.emailId305 } ?: return null306 val subtitle = if (identity.type == IdentityType.EMAIL) "email" else "username"307 val menu = simpleRow(context, identity.value, subtitle)308 return Dataset.Builder()309 .setValue(targetId, AutofillValue.forText(identity.value), menu)310 .build()311 }312313 314315316317318319320321322323 fun searchPeachDataset(324 context: Context,325 parsed: ParsedStructure,326 inlineSpec: android.widget.inline.InlinePresentationSpec?,327 unlocked: Boolean,328 hasMatches: Boolean,329 ): Dataset {330 val labels = if (unlocked) {331 PreviewLabels("Search Peach…", parsed.origin.orEmpty(), null)332 } else {333 PreviewLabels("Unlock Peach", parsed.origin.orEmpty(), null)334 }335 val menu = simpleRow(context, labels.title, labels.subtitle)336 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {337 buildInline(context, labels, inlineSpec, AutofillIntents.pickerChipPendingIntent(context, parsed))338 } else {339 null340 }341 val builder = Dataset.Builder(menu)342 (listOfNotNull(343 parsed.identityId, parsed.passwordId, parsed.otpId,344 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,345 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,346 ) + parsed.otpDigitBoxIds).forEach { id ->347 if (inline != null && Build.VERSION.SDK_INT >= 30) {348 builder.setValue(id, null, menu, inline)349 } else {350 builder.setValue(id, null, menu)351 }352 }353 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))354 return builder.build()355 }356357 358359360361362363364365366367 fun createNewEntryDataset(368 context: Context,369 parsed: ParsedStructure,370 inlineSpec: android.widget.inline.InlinePresentationSpec?,371 unlocked: Boolean,372 origin: String?,373 ): Dataset? {374 if (!unlocked) return null375 val labels = PreviewLabels("+ Save to Peach", origin.orEmpty(), null)376 val menu = simpleRow(context, labels.title, labels.subtitle)377 val inline = if (Build.VERSION.SDK_INT >= 30 && inlineSpec != null) {378 buildInline(context, labels, inlineSpec, AutofillIntents.newEntryChipPendingIntent(context, parsed, origin))379 } else {380 null381 }382 val builder = Dataset.Builder(menu)383 384 385 386 (listOfNotNull(387 parsed.identityId, parsed.passwordId, parsed.otpId,388 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,389 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,390 ) + parsed.otpDigitBoxIds).forEach { id ->391 if (inline != null && Build.VERSION.SDK_INT >= 30) {392 builder.setValue(id, null, menu, inline)393 } else {394 builder.setValue(id, null, menu)395 }396 }397 return builder.build()398 }399400 401 402 @SuppressLint("RestrictedApi")403 @RequiresApi(30)404 private fun buildInline(405 context: Context,406 labels: PreviewLabels,407 spec: android.widget.inline.InlinePresentationSpec,408 chipPending: PendingIntent = AutofillIntents.noopChipIntent(context),409 ): InlinePresentation? = try {410 val style = spec.style411 if (!UiVersions.getVersions(style).contains(UiVersions.INLINE_UI_VERSION_1)) {412 null413 } else {414 val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }415 ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)416 val slice = InlineSuggestionUi.newContentBuilder(chipPending)417 .setTitle(labels.title)418 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }419 .setStartIcon(startIcon)420 .build()421 .slice422 InlinePresentation(slice, spec, false)423 }424 } catch (_: Throwable) {425 426 null427 }428}