PeachAutofillService.kt

11.7 KB · raw

1package com.peachpasswords.autofill23import android.os.Build4import android.os.CancellationSignal5import android.service.autofill.AutofillService6import android.service.autofill.FillCallback7import android.service.autofill.FillRequest8import android.service.autofill.FillResponse9import android.service.autofill.SaveCallback10import android.service.autofill.SaveInfo11import android.service.autofill.SaveRequest12import com.peachpasswords.vault.VaultRepository13import com.peachpasswords.vault.isCard14import dagger.hilt.android.AndroidEntryPoint15import kotlinx.coroutines.CoroutineScope16import kotlinx.coroutines.Dispatchers17import kotlinx.coroutines.SupervisorJob18import kotlinx.coroutines.cancel19import kotlinx.coroutines.launch20import kotlinx.coroutines.withContext21import javax.inject.Inject2223/**24 * System autofill provider. Parses the focused form, then either offers25 * matching vault entries (inline chip + drop-down rows) or a single branded26 * "Search Peach…" entry that opens [AutofillEntryPickerActivity].27 *28 * The vault is a process singleton, so when the app has the vault unlocked the29 * service fills directly; when locked it offers only the picker, which routes30 * the user to unlock. Native save requests add or update login entries only31 * when the vault is already unlocked and the same strict origin policy passes.32 */33@AndroidEntryPoint34class PeachAutofillService : AutofillService() {3536    @Inject lateinit var vault: VaultRepository37    private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)3839    override fun onFillRequest(40        request: FillRequest,41        cancellationSignal: CancellationSignal,42        callback: FillCallback,43    ) {44        val context = applicationContext45        val structure = latestStructure(request) ?: run { callback.onSuccess(null); return }46        val parsed = resolveAutofillOrigin(context, AssistStructureParser.parse(structure))47        if (com.peachpasswords.BuildConfig.DEBUG) {48            android.util.Log.d(49                "PeachAutofill",50                "onFillRequest: hasCredentialField=${parsed.hasCredentialField} " +51                    "otpId=${parsed.otpId != null} origin=${parsed.origin} " +52                    "unlocked=${vault.isUnlocked.value} entryCount=${vault.getEntries().size} " +53                    "specCount=${inlineSpecs(request).size}",54            )55        }56        if (!parsed.hasCredentialField) { callback.onSuccess(null); return }5758        val inlineSpecs = inlineSpecs(request)59        val privatePreview = vault.settings.value.hideAutofillPreviews60        val unlocked = vault.isUnlocked.value61        val matches = if (unlocked) {62            AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all63        } else {64            emptyList()65        }66        val response = FillResponse.Builder()67        var datasetCount = 068        var hasLoginMatch = false69        // v2.14.8 chip-strip refinement: since v2.14.6 removed the "+ Save to70        // Peach" create chip, only the picker and (when eligible) the 2FA chip71        // need reserved slots. The OTP chip keeps the last inline spec slot72        // (the framework sends specs in field-order, so that is conventionally73        // the 2FA field on a username/password/2FA form; InlinePresentationSpec74        // carries no autofillId, so the last-slot heuristic is the closest75        // mapping the framework offers), and the picker takes the slot just76        // before it -- or the last slot itself when there is no OTP chip.77        // Every remaining slot becomes an entry chip, so a wide strip fills78        // completely and scrolls, and a narrow strip still lists everything79        // in the system drop-down menu. Gating otpEligible on MATCHED entries80        // (not any vault entry) stops a TOTP-less match set from orphaning81        // the last slot. v2.14.6 single-spec collision fix holds: with one82        // spec and an OTP chip eligible, the picker drops to the system menu83        // so the 2FA chip keeps the lone slot.84        val otpEligible = parsed.otpId != null && matches.any { !it.totp.isNullOrBlank() }85        val entryChipBudget = inlineChipBudget(inlineSpecs.size, otpEligible)86        val pickerSpec = pickerSpecFor(inlineSpecs, otpEligible)87        val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null88        var entryChips = 08990        if (unlocked) {91            // No artificial cap on matching entries: each entry gets its own92            // inline spec slot up to the budget; the IME renders what fits and93            // scrolls the rest. The system drop-down menu still lists every94            // matching entry, so the user is never locked out.95            matches.forEach { entry ->96                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null97                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {98                    response.addDataset(it)99                    datasetCount++100                    hasLoginMatch = true101                }102                // Dedicated 2FA chip on the last inline spec slot -- same103                // dataset family, OTP_FILL labels (chunked code as title,104                // "2FA" subtitle). Separate affordance from the LOGIN chip so105                // the user can verify the code before it lands.106                if (!entry.totp.isNullOrBlank() && otpSpec != null) {107                    AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {108                        response.addDataset(it)109                        datasetCount++110                    }111                }112            }113        }114115        if (parsed.hasCardField && unlocked) {116            vault.getEntries().filter { it.isCard() }.forEach { entry ->117                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null118                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {119                    response.addDataset(it)120                    datasetCount++121                }122            }123        }124125        // A3: offer to generate a strong password on signup forms (new-password126        // field) or any password form with no matching login, sized to the site's127        // detected policy. Gated to no-match so it never appears over a known128        // login the user is about to fill.129        if (unlocked && parsed.passwordId != null &&130            (parsed.isNewPassword || !hasLoginMatch)131        ) {132            runCatching {133                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {134                    response.addDataset(it)135                    datasetCount++136                }137            }138        }139140        // Always offer the branded picker as a system chip (search / unlock /141        // no-match). The chip lives in the reserved inline slot; the drop-down142        // menu still lists it when the IME offered no specs.143        runCatching {144            response.addDataset(145                AutofillResponseBuilder.searchPeachDataset(146                    context = context,147                    parsed = parsed,148                    inlineSpec = pickerSpec,149                    unlocked = unlocked,150                    hasMatches = hasLoginMatch,151                )152            )153            datasetCount++154        }155        if (datasetCount == 0) {156            callback.onSuccess(null)157        } else {158            ignoreUnfilledIds(response, parsed)159            addSaveInfo(response, parsed)160            callback.onSuccess(response.build())161        }162    }163164    /** Mark detected ids the framework should track even when we filled nothing. */165    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {166        val ids = listOfNotNull(167            parsed.identityId, parsed.passwordId, parsed.otpId,168            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,169            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,170        ) + parsed.otpDigitBoxIds171        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())172    }173174    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {175        val passwordId = parsed.passwordId ?: return176        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()177        val saveInfo = SaveInfo.Builder(178            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,179            arrayOf(passwordId),180        ).apply {181            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)182        }.build()183        builder.setSaveInfo(saveInfo)184    }185186    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =187        request.fillContexts.lastOrNull()?.structure188189    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {190        if (Build.VERSION.SDK_INT < 30) return emptyList()191        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()192    }193194    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {195        val structure = request.fillContexts.lastOrNull()?.structure196        if (structure == null || !vault.isUnlocked.value) {197            callback.onSuccess()198            return199        }200        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))201        val draft = autofillSaveDraft(parsed)202        if (draft == null) {203            callback.onSuccess()204            return205        }206        serviceScope.launch {207            try {208                withContext(Dispatchers.IO) {209                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {210                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)211                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)212                    }213                }214                callback.onSuccess()215            } catch (e: Exception) {216                callback.onFailure(e.message ?: "Peach could not save this login")217            }218        }219    }220221    override fun onDestroy() {222        serviceScope.cancel()223        super.onDestroy()224    }225226}227228/**229 * Number of inline slots reserved for system chips: one for the "Search230 * Peach" picker whenever any spec exists, plus one more for the dedicated231 * 2FA chip when eligible. v2.14.6 removed the "+ Save to Peach" create chip;232 * v2.14.8 hands its leftover reservation back to entry chips.233 */234internal fun inlineSystemChipCount(totalSpecs: Int, otpEligible: Boolean): Int =235    if (totalSpecs == 0) 0 else if (otpEligible) 2 else 1236237/** Entry/card chips get every inline slot not reserved for system chips. */238internal fun inlineChipBudget(totalSpecs: Int, otpEligible: Boolean): Int =239    (totalSpecs - inlineSystemChipCount(totalSpecs, otpEligible)).coerceAtLeast(0)240241internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {242    !unlocked -> "Unlock Peach"243    datasetCount == 0 -> "No saved logins for this site"244    else -> "Search Peach…"245}246247248/**249 * Pick the inline spec that hosts the "Search Peach…" chip, or null when250 * there isn't room. With an OTP chip eligible the picker takes the slot just251 * before it (the OTP chip owns the last spec); with a single spec the picker252 * falls back to the system menu so the 2FA chip keeps the lone inline slot253 * (v2.14.6 collision fix). Without an OTP chip the picker anchors the last254 * slot itself (v2.14.8 -- before, the stale two-slot reservation left the255 * last slot empty). The picker activity is always reachable from the256 * keyboard overflow menu, so dropping the inline chip never locks the257 * user out.258 */259internal fun <T> pickerSpecFor(specs: List<T>, otpEligible: Boolean): T? = when {260    specs.isEmpty() -> null261    otpEligible -> if (specs.size >= 2) specs[specs.size - 2] else null262    else -> specs.last()263}