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 = authenticateAutofillOrigin(context, AssistStructureParser.parse(structure))46        if (!parsed.hasUsableField) { callback.onSuccess(null); return }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)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 = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN && vault.isUnlocked.value) {67            "Verify site in Peach…"68        } else {69            autofillPickerLabel(70                unlocked = vault.isUnlocked.value,71                datasetCount = datasetCount,72            )73        }74        runCatching {75            response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))76            datasetCount++77        }7879        if (datasetCount == 0) {80            callback.onSuccess(null)81        } else {82            ignoreUnfilledIds(response, parsed)83            if (parsed.originTrust == AutofillOriginTrust.TRUSTED_WEB) {84                addSaveInfo(response, parsed)85            }86            callback.onSuccess(response.build())87        }88    }8990    private fun pickerDataset(91        context: android.content.Context,92        parsed: ParsedStructure,93        label: String,94        inlineSpec: android.widget.inline.InlinePresentationSpec?,95    ): android.service.autofill.Dataset {96        val subtitle = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {97            "Origin not verified"98        } else {99            parsed.origin.orEmpty()100        }101        val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)102        val builder = android.service.autofill.Dataset.Builder(menu)103        // A placeholder value per field keeps the dataset valid; the real values104        // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.105        listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId).forEach { id ->106            builder.setValue(id, null, menu)107        }108        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))109        return builder.build()110    }111112    /** Mark detected ids the framework should track even when we filled nothing. */113    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {114        val ids = listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId)115        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())116    }117118    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {119        val passwordId = parsed.passwordId ?: return120        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()121        val saveInfo = SaveInfo.Builder(122            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,123            arrayOf(passwordId),124        ).apply {125            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)126        }.build()127        builder.setSaveInfo(saveInfo)128    }129130    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =131        request.fillContexts.lastOrNull()?.structure132133    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {134        if (Build.VERSION.SDK_INT < 30) return emptyList()135        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()136    }137138    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {139        val structure = request.fillContexts.lastOrNull()?.structure140        if (structure == null || !vault.isUnlocked.value) {141            callback.onSuccess()142            return143        }144        val parsed = authenticateAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))145        if (parsed.originTrust != AutofillOriginTrust.TRUSTED_WEB) {146            callback.onSuccess()147            return148        }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}