PeachAutofillService.kt

7.3 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 dagger.hilt.android.AndroidEntryPoint14import kotlinx.coroutines.CoroutineScope15import kotlinx.coroutines.Dispatchers16import kotlinx.coroutines.SupervisorJob17import kotlinx.coroutines.cancel18import kotlinx.coroutines.launch19import kotlinx.coroutines.withContext20import javax.inject.Inject2122/**23 * System autofill provider. Parses the focused form, then either offers24 * matching vault entries (inline chip + drop-down rows) or a single branded25 * "Search Peach…" entry that opens [AutofillEntryPickerActivity].26 *27 * The vault is a process singleton, so when the app has the vault unlocked the28 * service fills directly; when locked it offers only the picker, which routes29 * the user to unlock. Native save requests add or update login entries only30 * when the vault is already unlocked and the same strict origin policy passes.31 */32@AndroidEntryPoint33class PeachAutofillService : AutofillService() {3435    @Inject lateinit var vault: VaultRepository36    private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)3738    override fun onFillRequest(39        request: FillRequest,40        cancellationSignal: CancellationSignal,41        callback: FillCallback,42    ) {43        val context = applicationContext44        val structure = latestStructure(request) ?: run { callback.onSuccess(null); return }45        val parsed = AssistStructureParser.parse(structure)46        if (!parsed.hasUsableField) { callback.onSuccess(null); return }4748        // MF-7: a web origin (webDomain) is only trustworthy from a known browser.49        // A non-browser app claiming a web domain is trying to elicit credentials it50        // does not own — refuse to offer anything at all.51        if (parsed.webDomain != null) {52            val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName53            if (!TrustedBrowsers.isTrusted(clientPackage)) { callback.onSuccess(null); return }54        }5556        val inlineSpecs = inlineSpecs(request)57        val response = FillResponse.Builder()58        var datasetCount = 05960        if (vault.isUnlocked.value) {61            val matches = AutofillResponseBuilder62                .matchEntries(vault.getEntries(), parsed.origin)63                .all.take(MAX_DATASETS)64            matches.forEachIndexed { index, entry ->65                val spec = inlineSpecs.getOrNull(index)66                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let {67                    response.addDataset(it)68                    datasetCount++69                }70            }71        }7273        // Always offer the branded picker as the last row (search / unlock / no-match).74        val pickerLabel = autofillPickerLabel(75            unlocked = vault.isUnlocked.value,76            datasetCount = datasetCount,77        )78        runCatching {79            response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))80            datasetCount++81        }8283        if (datasetCount == 0) {84            callback.onSuccess(null)85        } else {86            ignoreUnfilledIds(response, parsed)87            addSaveInfo(response, parsed)88            callback.onSuccess(response.build())89        }90    }9192    private fun pickerDataset(93        context: android.content.Context,94        parsed: ParsedStructure,95        label: String,96        inlineSpec: android.widget.inline.InlinePresentationSpec?,97    ): android.service.autofill.Dataset {98        val menu = AutofillResponseBuilder.simpleRow(context, label, parsed.origin ?: "")99        val builder = android.service.autofill.Dataset.Builder(menu)100        // A placeholder value per field keeps the dataset valid; the real values101        // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.102        listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId).forEach { id ->103            builder.setValue(id, null, menu)104        }105        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))106        return builder.build()107    }108109    /** Mark detected ids the framework should track even when we filled nothing. */110    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {111        val ids = listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId)112        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())113    }114115    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {116        val passwordId = parsed.passwordId ?: return117        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()118        val saveInfo = SaveInfo.Builder(119            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,120            arrayOf(passwordId),121        ).apply {122            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)123        }.build()124        builder.setSaveInfo(saveInfo)125    }126127    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =128        request.fillContexts.lastOrNull()?.structure129130    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {131        if (Build.VERSION.SDK_INT < 30) return emptyList()132        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()133    }134135    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {136        val structure = request.fillContexts.lastOrNull()?.structure137        if (structure == null || !vault.isUnlocked.value) {138            callback.onSuccess()139            return140        }141        val parsed = AssistStructureParser.parse(structure)142        if (parsed.webDomain != null) {143            val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName144            if (!TrustedBrowsers.isTrusted(clientPackage)) {145                callback.onSuccess()146                return147            }148        }149        val draft = autofillSaveDraft(parsed)150        if (draft == null) {151            callback.onSuccess()152            return153        }154        serviceScope.launch {155            try {156                withContext(Dispatchers.IO) {157                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {158                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)159                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)160                    }161                }162                callback.onSuccess()163            } catch (e: Exception) {164                callback.onFailure(e.message ?: "Peach could not save this login")165            }166        }167    }168169    override fun onDestroy() {170        serviceScope.cancel()171        super.onDestroy()172    }173174    private companion object {175        const val MAX_DATASETS = 6176    }177}178179internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {180    !unlocked -> "Unlock Peach"181    datasetCount == 0 -> "No saved logins for this site"182    else -> "Search Peach…"183}