PeachAutofillService.kt

8.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.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 = authenticateAutofillOrigin(context, AssistStructureParser.parse(structure))47        if (!parsed.hasUsableField) { callback.onSuccess(null); return }4849        val inlineSpecs = inlineSpecs(request)50        val response = FillResponse.Builder()51        var datasetCount = 05253        if (vault.isUnlocked.value) {54            val matches = AutofillResponseBuilder55                .matchEntries(vault.getEntries(), parsed)56                .all.take(MAX_DATASETS)57            matches.forEachIndexed { index, entry ->58                val spec = inlineSpecs.getOrNull(index)59                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let {60                    response.addDataset(it)61                    datasetCount++62                }63            }64        }6566        if (parsed.hasCardField && vault.isUnlocked.value) {67            vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEachIndexed { index, entry ->68                val spec = inlineSpecs.getOrNull(datasetCount + index)69                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec)?.let {70                    response.addDataset(it)71                    datasetCount++72                }73            }74        }7576        // Always offer the branded picker as the last row (search / unlock / no-match).77        val pickerLabel = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN && vault.isUnlocked.value) {78            "Verify site in Peach…"79        } else {80            autofillPickerLabel(81                unlocked = vault.isUnlocked.value,82                datasetCount = datasetCount,83            )84        }85        runCatching {86            response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))87            datasetCount++88        }8990        if (datasetCount == 0) {91            callback.onSuccess(null)92        } else {93            ignoreUnfilledIds(response, parsed)94            if (parsed.originTrust == AutofillOriginTrust.TRUSTED_WEB) {95                addSaveInfo(response, parsed)96            }97            callback.onSuccess(response.build())98        }99    }100101    private fun pickerDataset(102        context: android.content.Context,103        parsed: ParsedStructure,104        label: String,105        inlineSpec: android.widget.inline.InlinePresentationSpec?,106    ): android.service.autofill.Dataset {107        val subtitle = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {108            "Origin not verified"109        } else {110            parsed.origin.orEmpty()111        }112        val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)113        val builder = android.service.autofill.Dataset.Builder(menu)114        // A placeholder value per field keeps the dataset valid; the real values115        // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.116        listOfNotNull(117            parsed.identityId, parsed.passwordId, parsed.otpId,118            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,119            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,120        ).forEach { id ->121            builder.setValue(id, null, menu)122        }123        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))124        return builder.build()125    }126127    /** Mark detected ids the framework should track even when we filled nothing. */128    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {129        val ids = listOfNotNull(130            parsed.identityId, parsed.passwordId, parsed.otpId,131            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,132            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,133        )134        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())135    }136137    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {138        val passwordId = parsed.passwordId ?: return139        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()140        val saveInfo = SaveInfo.Builder(141            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,142            arrayOf(passwordId),143        ).apply {144            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)145        }.build()146        builder.setSaveInfo(saveInfo)147    }148149    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =150        request.fillContexts.lastOrNull()?.structure151152    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {153        if (Build.VERSION.SDK_INT < 30) return emptyList()154        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()155    }156157    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {158        val structure = request.fillContexts.lastOrNull()?.structure159        if (structure == null || !vault.isUnlocked.value) {160            callback.onSuccess()161            return162        }163        val parsed = authenticateAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))164        if (parsed.originTrust != AutofillOriginTrust.TRUSTED_WEB) {165            callback.onSuccess()166            return167        }168        val draft = autofillSaveDraft(parsed)169        if (draft == null) {170            callback.onSuccess()171            return172        }173        serviceScope.launch {174            try {175                withContext(Dispatchers.IO) {176                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {177                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)178                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)179                    }180                }181                callback.onSuccess()182            } catch (e: Exception) {183                callback.onFailure(e.message ?: "Peach could not save this login")184            }185        }186    }187188    override fun onDestroy() {189        serviceScope.cancel()190        super.onDestroy()191    }192193    private companion object {194        const val MAX_DATASETS = 6195    }196}197198internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {199    !unlocked -> "Unlock Peach"200    datasetCount == 0 -> "No saved logins for this site"201    else -> "Search Peach…"202}