PeachAutofillService.kt

8.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 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.hasUsableField) { callback.onSuccess(null); return }4849        val inlineSpecs = inlineSpecs(request)50        val response = FillResponse.Builder()51        var datasetCount = 052        var hasLoginMatch = false5354        if (vault.isUnlocked.value) {55            val matches = AutofillResponseBuilder56                .matchEntries(vault.getEntries(), parsed)57                .all.take(MAX_DATASETS)58            matches.forEachIndexed { index, entry ->59                val spec = inlineSpecs.getOrNull(index)60                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let {61                    response.addDataset(it)62                    datasetCount++63                    hasLoginMatch = true64                }65            }66        }6768        if (parsed.hasCardField && vault.isUnlocked.value) {69            vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEachIndexed { index, entry ->70                val spec = inlineSpecs.getOrNull(datasetCount + index)71                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec)?.let {72                    response.addDataset(it)73                    datasetCount++74                }75            }76        }7778        // A3: offer to generate a strong password on signup forms (new-password79        // field) or any password form with no matching login, sized to the site's80        // detected policy. Gated to no-match so it never appears over a known81        // login the user is about to fill.82        if (vault.isUnlocked.value && parsed.passwordId != null &&83            (parsed.isNewPassword || !hasLoginMatch)84        ) {85            runCatching {86                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {87                    response.addDataset(it)88                    datasetCount++89                }90            }91        }9293        // Always offer the branded picker as the last row (search / unlock / no-match).94        val pickerLabel = autofillPickerLabel(95            unlocked = vault.isUnlocked.value,96            datasetCount = datasetCount,97        )98        runCatching {99            response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))100            datasetCount++101        }102103        if (datasetCount == 0) {104            callback.onSuccess(null)105        } else {106            ignoreUnfilledIds(response, parsed)107            addSaveInfo(response, parsed)108            callback.onSuccess(response.build())109        }110    }111112    private fun pickerDataset(113        context: android.content.Context,114        parsed: ParsedStructure,115        label: String,116        inlineSpec: android.widget.inline.InlinePresentationSpec?,117    ): android.service.autofill.Dataset {118        val subtitle = parsed.origin.orEmpty()119        val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)120        val builder = android.service.autofill.Dataset.Builder(menu)121        // A placeholder value per field keeps the dataset valid; the real values122        // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.123        (listOfNotNull(124            parsed.identityId, parsed.passwordId, parsed.otpId,125            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,126            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,127        ) + parsed.otpDigitBoxIds).forEach { id ->128            builder.setValue(id, null, menu)129        }130        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))131        return builder.build()132    }133134    /** Mark detected ids the framework should track even when we filled nothing. */135    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {136        val ids = listOfNotNull(137            parsed.identityId, parsed.passwordId, parsed.otpId,138            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,139            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,140        ) + parsed.otpDigitBoxIds141        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())142    }143144    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {145        val passwordId = parsed.passwordId ?: return146        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()147        val saveInfo = SaveInfo.Builder(148            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,149            arrayOf(passwordId),150        ).apply {151            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)152        }.build()153        builder.setSaveInfo(saveInfo)154    }155156    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =157        request.fillContexts.lastOrNull()?.structure158159    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {160        if (Build.VERSION.SDK_INT < 30) return emptyList()161        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()162    }163164    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {165        val structure = request.fillContexts.lastOrNull()?.structure166        if (structure == null || !vault.isUnlocked.value) {167            callback.onSuccess()168            return169        }170        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))171        val draft = autofillSaveDraft(parsed)172        if (draft == null) {173            callback.onSuccess()174            return175        }176        serviceScope.launch {177            try {178                withContext(Dispatchers.IO) {179                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {180                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)181                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)182                    }183                }184                callback.onSuccess()185            } catch (e: Exception) {186                callback.onFailure(e.message ?: "Peach could not save this login")187            }188        }189    }190191    override fun onDestroy() {192        serviceScope.cancel()193        super.onDestroy()194    }195196    private companion object {197        const val MAX_DATASETS = 6198    }199}200201internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {202    !unlocked -> "Unlock Peach"203    datasetCount == 0 -> "No saved logins for this site"204    else -> "Search Peach…"205}