PeachAutofillService.kt

5.0 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.SaveRequest11import com.peachpasswords.vault.VaultRepository12import dagger.hilt.android.AndroidEntryPoint13import javax.inject.Inject1415/**16 * System autofill provider. Parses the focused form, then either offers17 * matching vault entries (inline chip + drop-down rows) or a single branded18 * "Search Peach…" entry that opens [AutofillEntryPickerActivity].19 *20 * The vault is a process singleton, so when the app has the vault unlocked the21 * service fills directly; when locked it offers only the picker, which routes22 * the user to unlock. The save flow is intentionally a no-op for now — Peach23 * adds entries through its own UI, not the system Save dialog.24 */25@AndroidEntryPoint26class PeachAutofillService : AutofillService() {2728    @Inject lateinit var vault: VaultRepository2930    override fun onFillRequest(31        request: FillRequest,32        cancellationSignal: CancellationSignal,33        callback: FillCallback,34    ) {35        val context = applicationContext36        val structure = latestStructure(request) ?: run { callback.onSuccess(null); return }37        val parsed = AssistStructureParser.parse(structure)38        if (!parsed.hasUsableField) { callback.onSuccess(null); return }3940        // MF-7: a web origin (webDomain) is only trustworthy from a known browser.41        // A non-browser app claiming a web domain is trying to elicit credentials it42        // does not own — refuse to offer anything at all.43        if (parsed.webDomain != null) {44            val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName45            if (!TrustedBrowsers.isTrusted(clientPackage)) { callback.onSuccess(null); return }46        }4748        val inlineSpecs = inlineSpecs(request)49        val response = FillResponse.Builder()50        var datasetCount = 05152        if (vault.isUnlocked.value) {53            val matches = AutofillResponseBuilder54                .matchEntries(vault.getEntries(), parsed.origin)55                .all.take(MAX_DATASETS)56            matches.forEachIndexed { index, entry ->57                val spec = inlineSpecs.getOrNull(index)58                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let {59                    response.addDataset(it)60                    datasetCount++61                }62            }63        }6465        // Always offer the branded picker as the last row (search / unlock / no-match).66        val pickerLabel = when {67            !vault.isUnlocked.value -> "Unlock Peach"68            datasetCount == 0 -> "No saved logins for this site"69            else -> "Search Peach…"70        }71        runCatching {72            response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))73            datasetCount++74        }7576        if (datasetCount == 0) {77            callback.onSuccess(null)78        } else {79            ignoreUnfilledIds(response, parsed)80            callback.onSuccess(response.build())81        }82    }8384    private fun pickerDataset(85        context: android.content.Context,86        parsed: ParsedStructure,87        label: String,88        inlineSpec: android.widget.inline.InlinePresentationSpec?,89    ): android.service.autofill.Dataset {90        val menu = AutofillResponseBuilder.simpleRow(context, label, parsed.origin ?: "")91        val builder = android.service.autofill.Dataset.Builder(menu)92        // A placeholder value per field keeps the dataset valid; the real values93        // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.94        listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId).forEach { id ->95            builder.setValue(id, null, menu)96        }97        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))98        return builder.build()99    }100101    /** Mark detected ids the framework should track even when we filled nothing. */102    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {103        val ids = listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId)104        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())105    }106107    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =108        request.fillContexts.lastOrNull()?.structure109110    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {111        if (Build.VERSION.SDK_INT < 30) return emptyList()112        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()113    }114115    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {116        // Peach captures new logins through its own add-entry flow.117        callback.onSuccess()118    }119120    private companion object {121        const val MAX_DATASETS = 6122    }123}