PeachAutofillService.kt

7.2 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 = when {75            !vault.isUnlocked.value -> "Unlock Peach"76            datasetCount == 0 -> "No saved logins for this site"77            else -> "Search Peach…"78        }79        runCatching {80            response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))81            datasetCount++82        }8384        if (datasetCount == 0) {85            callback.onSuccess(null)86        } else {87            ignoreUnfilledIds(response, parsed)88            addSaveInfo(response, parsed)89            callback.onSuccess(response.build())90        }91    }9293    private fun pickerDataset(94        context: android.content.Context,95        parsed: ParsedStructure,96        label: String,97        inlineSpec: android.widget.inline.InlinePresentationSpec?,98    ): android.service.autofill.Dataset {99        val menu = AutofillResponseBuilder.simpleRow(context, label, parsed.origin ?: "")100        val builder = android.service.autofill.Dataset.Builder(menu)101        // A placeholder value per field keeps the dataset valid; the real values102        // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.103        listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId).forEach { id ->104            builder.setValue(id, null, menu)105        }106        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))107        return builder.build()108    }109110    /** Mark detected ids the framework should track even when we filled nothing. */111    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {112        val ids = listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId)113        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())114    }115116    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {117        val passwordId = parsed.passwordId ?: return118        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()119        val saveInfo = SaveInfo.Builder(120            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,121            arrayOf(passwordId),122        ).apply {123            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)124        }.build()125        builder.setSaveInfo(saveInfo)126    }127128    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =129        request.fillContexts.lastOrNull()?.structure130131    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {132        if (Build.VERSION.SDK_INT < 30) return emptyList()133        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()134    }135136    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {137        val structure = request.fillContexts.lastOrNull()?.structure138        if (structure == null || !vault.isUnlocked.value) {139            callback.onSuccess()140            return141        }142        val parsed = AssistStructureParser.parse(structure)143        if (parsed.webDomain != null) {144            val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName145            if (!TrustedBrowsers.isTrusted(clientPackage)) {146                callback.onSuccess()147                return148            }149        }150        val draft = autofillSaveDraft(parsed)151        if (draft == null) {152            callback.onSuccess()153            return154        }155        serviceScope.launch {156            try {157                withContext(Dispatchers.IO) {158                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {159                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)160                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)161                    }162                }163                callback.onSuccess()164            } catch (e: Exception) {165                callback.onFailure(e.message ?: "Peach could not save this login")166            }167        }168    }169170    override fun onDestroy() {171        serviceScope.cancel()172        super.onDestroy()173    }174175    private companion object {176        const val MAX_DATASETS = 6177    }178}