PeachAutofillService.kt

8.4 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 = false5455        if (vault.isUnlocked.value) {56            val matches = AutofillResponseBuilder57                .matchEntries(vault.getEntries(), parsed)58                .all.take(MAX_DATASETS)59            matches.forEachIndexed { index, entry ->60                val spec = inlineSpecs.getOrNull(index)61                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {62                    response.addDataset(it)63                    datasetCount++64                    hasLoginMatch = true65                }66            }67        }6869        if (parsed.hasCardField && vault.isUnlocked.value) {70            vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEachIndexed { index, entry ->71                val spec = inlineSpecs.getOrNull(datasetCount + index)72                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {73                    response.addDataset(it)74                    datasetCount++75                }76            }77        }7879        // A3: offer to generate a strong password on signup forms (new-password80        // field) or any password form with no matching login, sized to the site's81        // detected policy. Gated to no-match so it never appears over a known82        // login the user is about to fill.83        if (vault.isUnlocked.value && parsed.passwordId != null &&84            (parsed.isNewPassword || !hasLoginMatch)85        ) {86            runCatching {87                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {88                    response.addDataset(it)89                    datasetCount++90                }91            }92        }9394        // Always offer the branded picker as the last row (search / unlock / no-match).95        val pickerLabel = autofillPickerLabel(96            unlocked = vault.isUnlocked.value,97            datasetCount = datasetCount,98        )99        runCatching {100            response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))101            datasetCount++102        }103104        if (datasetCount == 0) {105            callback.onSuccess(null)106        } else {107            ignoreUnfilledIds(response, parsed)108            addSaveInfo(response, parsed)109            callback.onSuccess(response.build())110        }111    }112113    private fun pickerDataset(114        context: android.content.Context,115        parsed: ParsedStructure,116        label: String,117        inlineSpec: android.widget.inline.InlinePresentationSpec?,118    ): android.service.autofill.Dataset {119        val subtitle = parsed.origin.orEmpty()120        val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)121        val builder = android.service.autofill.Dataset.Builder(menu)122        // A placeholder value per field keeps the dataset valid; the real values123        // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT.124        (listOfNotNull(125            parsed.identityId, parsed.passwordId, parsed.otpId,126            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,127            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,128        ) + parsed.otpDigitBoxIds).forEach { id ->129            builder.setValue(id, null, menu)130        }131        builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))132        return builder.build()133    }134135    /** Mark detected ids the framework should track even when we filled nothing. */136    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {137        val ids = listOfNotNull(138            parsed.identityId, parsed.passwordId, parsed.otpId,139            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,140            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,141        ) + parsed.otpDigitBoxIds142        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())143    }144145    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {146        val passwordId = parsed.passwordId ?: return147        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()148        val saveInfo = SaveInfo.Builder(149            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,150            arrayOf(passwordId),151        ).apply {152            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)153        }.build()154        builder.setSaveInfo(saveInfo)155    }156157    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =158        request.fillContexts.lastOrNull()?.structure159160    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {161        if (Build.VERSION.SDK_INT < 30) return emptyList()162        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()163    }164165    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {166        val structure = request.fillContexts.lastOrNull()?.structure167        if (structure == null || !vault.isUnlocked.value) {168            callback.onSuccess()169            return170        }171        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))172        val draft = autofillSaveDraft(parsed)173        if (draft == null) {174            callback.onSuccess()175            return176        }177        serviceScope.launch {178            try {179                withContext(Dispatchers.IO) {180                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {181                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)182                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)183                    }184                }185                callback.onSuccess()186            } catch (e: Exception) {187                callback.onFailure(e.message ?: "Peach could not save this login")188            }189        }190    }191192    override fun onDestroy() {193        serviceScope.cancel()194        super.onDestroy()195    }196197    private companion object {198        const val MAX_DATASETS = 6199    }200}201202internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {203    !unlocked -> "Unlock Peach"204    datasetCount == 0 -> "No saved logins for this site"205    else -> "Search Peach…"206}