PeachAutofillService.kt

9.6 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 unlocked = vault.isUnlocked.value52        val response = FillResponse.Builder()53        var datasetCount = 054        var hasLoginMatch = false55        // Reserve the last inline slots for the system chips (picker + create)56        // so the full vault stays reachable from the keyboard row even when57        // entry chips fill every other slot. The IME scrolls horizontally for58        // overflow on wide enough strips; on narrow strips the user can still59        // reach every matching entry via the system drop-down menu.60        val systemChipCount = inlineSystemChipCount(inlineSpecs.size)61        val entryChipBudget = (inlineSpecs.size - systemChipCount).coerceAtLeast(0)62        val pickerSpec = if (inlineSpecs.size >= 2) {63            inlineSpecs[inlineSpecs.size - 2]64        } else {65            inlineSpecs.lastOrNull()66        }67        val createSpec = inlineSpecs.lastOrNull()68        var entryChips = 06970        if (unlocked) {71            // No artificial cap on matching entries: each entry gets its own72            // inline spec slot up to the budget; the IME renders what fits and73            // scrolls the rest. The system drop-down menu still lists every74            // matching entry, so the user is never locked out.75            val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all76            matches.forEach { entry ->77                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null78                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {79                    response.addDataset(it)80                    datasetCount++81                    hasLoginMatch = true82                }83            }84        }8586        if (parsed.hasCardField && unlocked) {87            vault.getEntries().filter { it.isCard() }.forEach { entry ->88                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null89                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {90                    response.addDataset(it)91                    datasetCount++92                }93            }94        }9596        // A3: offer to generate a strong password on signup forms (new-password97        // field) or any password form with no matching login, sized to the site's98        // detected policy. Gated to no-match so it never appears over a known99        // login the user is about to fill.100        if (unlocked && parsed.passwordId != null &&101            (parsed.isNewPassword || !hasLoginMatch)102        ) {103            runCatching {104                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {105                    response.addDataset(it)106                    datasetCount++107                }108            }109        }110111        // Always offer the branded picker as a system chip (search / unlock /112        // no-match) and the "+ Save to Peach" create chip when the vault is113        // unlocked. Both chips live in the reserved last inline slots; the114        // drop-down menu still lists them when the IME offered no specs.115        runCatching {116            response.addDataset(117                AutofillResponseBuilder.searchPeachDataset(118                    context = context,119                    parsed = parsed,120                    inlineSpec = pickerSpec,121                    unlocked = unlocked,122                    hasMatches = hasLoginMatch,123                )124            )125            datasetCount++126        }127        AutofillResponseBuilder.createNewEntryDataset(128            context = context,129            parsed = parsed,130            inlineSpec = createSpec,131            unlocked = unlocked,132            origin = parsed.origin,133        )?.let {134            response.addDataset(it)135            datasetCount++136        }137138        if (datasetCount == 0) {139            callback.onSuccess(null)140        } else {141            ignoreUnfilledIds(response, parsed)142            addSaveInfo(response, parsed)143            callback.onSuccess(response.build())144        }145    }146147    /** Mark detected ids the framework should track even when we filled nothing. */148    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {149        val ids = listOfNotNull(150            parsed.identityId, parsed.passwordId, parsed.otpId,151            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,152            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,153        ) + parsed.otpDigitBoxIds154        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())155    }156157    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {158        val passwordId = parsed.passwordId ?: return159        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()160        val saveInfo = SaveInfo.Builder(161            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,162            arrayOf(passwordId),163        ).apply {164            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)165        }.build()166        builder.setSaveInfo(saveInfo)167    }168169    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =170        request.fillContexts.lastOrNull()?.structure171172    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {173        if (Build.VERSION.SDK_INT < 30) return emptyList()174        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()175    }176177    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {178        val structure = request.fillContexts.lastOrNull()?.structure179        if (structure == null || !vault.isUnlocked.value) {180            callback.onSuccess()181            return182        }183        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))184        val draft = autofillSaveDraft(parsed)185        if (draft == null) {186            callback.onSuccess()187            return188        }189        serviceScope.launch {190            try {191                withContext(Dispatchers.IO) {192                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {193                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)194                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)195                    }196                }197                callback.onSuccess()198            } catch (e: Exception) {199                callback.onFailure(e.message ?: "Peach could not save this login")200            }201        }202    }203204    override fun onDestroy() {205        serviceScope.cancel()206        super.onDestroy()207    }208209}210211/**212 * Number of inline slots reserved for system chips (Search Peach / + Save to213 * Peach). With 2+ available slots we keep one each; with a single slot the214 * picker wins (create gets the drop-down row but no chip), since unlocking the215 * picker is the more common next action than creating a new entry.216 */217internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0218219/**220 * Entry/card chips get every inline slot not reserved for system chips.221 * Preserved for the existing unit test — the live service uses222 * [inlineSystemChipCount] directly.223 */224internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).coerceAtLeast(0)225226internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {227    !unlocked -> "Unlock Peach"228    datasetCount == 0 -> "No saved logins for this site"229    else -> "Search Peach…"230}