PeachAutofillService.kt

8.9 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 (!parsed.hasCredentialField) { callback.onSuccess(null); return }4849        val inlineSpecs = inlineSpecs(request)50        val privatePreview = vault.settings.value.hideAutofillPreviews51        val response = FillResponse.Builder()52        var datasetCount = 053        var hasLoginMatch = false54        // Reserve the last inline slot for the branded picker chip so the full55        // vault stays reachable from the keyboard row even when entry chips56        // fill every other slot.57        val pickerSpec = inlineSpecs.lastOrNull()58        val entryChipBudget = inlineChipBudget(inlineSpecs.size)59        var entryChips = 06061        if (vault.isUnlocked.value) {62            val matches = AutofillResponseBuilder63                .matchEntries(vault.getEntries(), parsed)64                .all.take(MAX_DATASETS)65            matches.forEach { entry ->66                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null67                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {68                    response.addDataset(it)69                    datasetCount++70                    hasLoginMatch = true71                }72            }73        }7475        if (parsed.hasCardField && vault.isUnlocked.value) {76            vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEach { entry ->77                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null78                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {79                    response.addDataset(it)80                    datasetCount++81                }82            }83        }8485        // A3: offer to generate a strong password on signup forms (new-password86        // field) or any password form with no matching login, sized to the site's87        // detected policy. Gated to no-match so it never appears over a known88        // login the user is about to fill.89        if (vault.isUnlocked.value && parsed.passwordId != null &&90            (parsed.isNewPassword || !hasLoginMatch)91        ) {92            runCatching {93                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {94                    response.addDataset(it)95                    datasetCount++96                }97            }98        }99100        // Always offer the branded picker as the last row (search / unlock / no-match).101        val pickerLabel = autofillPickerLabel(102            unlocked = vault.isUnlocked.value,103            datasetCount = datasetCount,104        )105        runCatching {106            response.addDataset(pickerDataset(context, parsed, pickerLabel, pickerSpec))107            datasetCount++108        }109110        if (datasetCount == 0) {111            callback.onSuccess(null)112        } else {113            ignoreUnfilledIds(response, parsed)114            addSaveInfo(response, parsed)115            callback.onSuccess(response.build())116        }117    }118119    private fun pickerDataset(120        context: android.content.Context,121        parsed: ParsedStructure,122        label: String,123        inlineSpec: android.widget.inline.InlinePresentationSpec?,124    ): android.service.autofill.Dataset {125        val subtitle = parsed.origin.orEmpty()126        val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)127        val builder = android.service.autofill.Dataset.Builder(menu)128        // A placeholder value per field keeps the dataset valid; the real values129        // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.130        (listOfNotNull(131            parsed.identityId, parsed.passwordId, parsed.otpId,132            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,133            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,134        ) + parsed.otpDigitBoxIds).forEach { id ->135            builder.setValue(id, null, menu)136        }137        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))138        return builder.build()139    }140141    /** Mark detected ids the framework should track even when we filled nothing. */142    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {143        val ids = listOfNotNull(144            parsed.identityId, parsed.passwordId, parsed.otpId,145            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,146            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,147        ) + parsed.otpDigitBoxIds148        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())149    }150151    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {152        val passwordId = parsed.passwordId ?: return153        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()154        val saveInfo = SaveInfo.Builder(155            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,156            arrayOf(passwordId),157        ).apply {158            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)159        }.build()160        builder.setSaveInfo(saveInfo)161    }162163    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =164        request.fillContexts.lastOrNull()?.structure165166    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {167        if (Build.VERSION.SDK_INT < 30) return emptyList()168        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()169    }170171    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {172        val structure = request.fillContexts.lastOrNull()?.structure173        if (structure == null || !vault.isUnlocked.value) {174            callback.onSuccess()175            return176        }177        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))178        val draft = autofillSaveDraft(parsed)179        if (draft == null) {180            callback.onSuccess()181            return182        }183        serviceScope.launch {184            try {185                withContext(Dispatchers.IO) {186                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {187                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)188                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)189                    }190                }191                callback.onSuccess()192            } catch (e: Exception) {193                callback.onFailure(e.message ?: "Peach could not save this login")194            }195        }196    }197198    override fun onDestroy() {199        serviceScope.cancel()200        super.onDestroy()201    }202203    private companion object {204        const val MAX_DATASETS = 6205    }206}207208/** Entry/card chips get every inline slot except the one reserved for the picker. */209internal fun inlineChipBudget(totalSpecs: Int): Int = if (totalSpecs > 0) totalSpecs - 1 else 0210211internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {212    !unlocked -> "Unlock Peach"213    datasetCount == 0 -> "No saved logins for this site"214    else -> "Search Peach…"215}