PeachAutofillService.kt

13.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 com.peachpasswords.shared.PeachClipboard14import com.peachpasswords.vault.TOTPGenerator15import com.peachpasswords.vault.isCard16import dagger.hilt.android.AndroidEntryPoint17import kotlinx.coroutines.CoroutineScope18import kotlinx.coroutines.Dispatchers19import kotlinx.coroutines.SupervisorJob20import kotlinx.coroutines.cancel21import kotlinx.coroutines.launch22import kotlinx.coroutines.withContext23import javax.inject.Inject2425/**26 * System autofill provider. Parses the focused form, then either offers27 * matching vault entries (inline chip + drop-down rows) or a single branded28 * "Search Peach…" entry that opens [AutofillEntryPickerActivity].29 *30 * The vault is a process singleton, so when the app has the vault unlocked the31 * service fills directly; when locked it offers only the picker, which routes32 * the user to unlock. Native save requests add or update login entries only33 * when the vault is already unlocked and the same strict origin policy passes.34 */35@AndroidEntryPoint36class PeachAutofillService : AutofillService() {3738    @Inject lateinit var vault: VaultRepository39    private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)4041    override fun onFillRequest(42        request: FillRequest,43        cancellationSignal: CancellationSignal,44        callback: FillCallback,45    ) {46        val context = applicationContext47        val structure = latestStructure(request) ?: run { callback.onSuccess(null); return }48        val parsed = resolveAutofillOrigin(context, AssistStructureParser.parse(structure))49        if (com.peachpasswords.BuildConfig.DEBUG) {50            android.util.Log.d(51                "PeachAutofill",52                "onFillRequest: hasCredentialField=${parsed.hasCredentialField} " +53                    "otpId=${parsed.otpId != null} origin=${parsed.origin} " +54                    "unlocked=${vault.isUnlocked.value} entryCount=${vault.getEntries().size} " +55                    "specCount=${inlineSpecs(request).size}",56            )57        }58        if (!parsed.hasCredentialField) { callback.onSuccess(null); return }5960        val inlineSpecs = inlineSpecs(request)61        val privatePreview = vault.settings.value.hideAutofillPreviews62        val unlocked = vault.isUnlocked.value63        val matches = if (unlocked) {64            AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all65        } else {66            emptyList()67        }68        val response = FillResponse.Builder()69        var datasetCount = 070        var hasLoginMatch = false71        // v2.14.8 chip-strip refinement: since v2.14.6 removed the "+ Save to72        // Peach" create chip, only the picker and (when eligible) the 2FA chip73        // need reserved slots. The OTP chip keeps the last inline spec slot74        // (the framework sends specs in field-order, so that is conventionally75        // the 2FA field on a username/password/2FA form; InlinePresentationSpec76        // carries no autofillId, so the last-slot heuristic is the closest77        // mapping the framework offers), and the picker takes the slot just78        // before it -- or the last slot itself when there is no OTP chip.79        // Every remaining slot becomes an entry chip, so a wide strip fills80        // completely and scrolls, and a narrow strip still lists everything81        // in the system drop-down menu. Gating otpEligible on MATCHED entries82        // (not any vault entry) stops a TOTP-less match set from orphaning83        // the last slot. v2.14.6 single-spec collision fix holds: with one84        // spec and an OTP chip eligible, the picker drops to the system menu85        // so the 2FA chip keeps the lone slot.86        val otpEligible = parsed.otpId != null && matches.any { !it.totp.isNullOrBlank() }87        val entryChipBudget = inlineChipBudget(inlineSpecs.size, otpEligible)88        val pickerSpec = pickerSpecFor(inlineSpecs, otpEligible)89        val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null90        var entryChips = 09192        if (unlocked) {93            // No artificial cap on matching entries: each entry gets its own94            // inline spec slot up to the budget; the IME renders what fits and95            // scrolls the rest. The system drop-down menu still lists every96            // matching entry, so the user is never locked out.97            matches.forEach { entry ->98                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null99                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {100                    response.addDataset(it)101                    datasetCount++102                    hasLoginMatch = true103                }104                // Dedicated 2FA chip on the last inline spec slot -- same105                // dataset family, OTP_FILL labels (chunked code as title,106                // "2FA" subtitle). Separate affordance from the LOGIN chip so107                // the user can verify the code before it lands.108                if (!entry.totp.isNullOrBlank() && otpSpec != null) {109                    AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {110                        response.addDataset(it)111                        datasetCount++112                    }113                }114            }115            // Clipboard safety net: when ANY matched entry has a TOTP, copy116            // its freshly-generated code to the clipboard so the user can117            // long-press the 2FA field → Paste even if the OTP chip fails118            // to surface (custom labels, spec-slot mismatches, lifecycle119            // gaps). PeachClipboard.copy marks the clip IS_SENSITIVE and120            // auto-clears after 30s — never stomps a later user copy.121            // Wrapped in runCatching so a clipboard-service hiccup can122            // never crash the FillRequest handler (which would leave the123            // user with no chips at all).124            runCatching {125                matches.firstOrNull { !it.totp.isNullOrBlank() }?.let { entry ->126                    TOTPGenerator.generateCode(entry.totp!!)?.let { code ->127                        PeachClipboard.copy(context, label = "Peach 2FA", value = code, hint = true)128                        if (com.peachpasswords.BuildConfig.DEBUG) {129                            android.util.Log.d(130                                "PeachAutofill",131                                "2FA clipboard copy fired (entry=" + entry.title + ", code=" + code + ")",132                            )133                        }134                    }135                }136            }.onFailure { t ->137                android.util.Log.w("PeachAutofill", "2FA clipboard copy failed", t)138            }139        }140141        if (parsed.hasCardField && unlocked) {142            vault.getEntries().filter { it.isCard() }.forEach { entry ->143                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null144                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {145                    response.addDataset(it)146                    datasetCount++147                }148            }149        }150151        // A3: offer to generate a strong password on signup forms (new-password152        // field) or any password form with no matching login, sized to the site's153        // detected policy. Gated to no-match so it never appears over a known154        // login the user is about to fill.155        if (unlocked && parsed.passwordId != null &&156            (parsed.isNewPassword || !hasLoginMatch)157        ) {158            runCatching {159                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {160                    response.addDataset(it)161                    datasetCount++162                }163            }164        }165166        // Always offer the branded picker as a system chip (search / unlock /167        // no-match). The chip lives in the reserved inline slot; the drop-down168        // menu still lists it when the IME offered no specs.169        runCatching {170            response.addDataset(171                AutofillResponseBuilder.searchPeachDataset(172                    context = context,173                    parsed = parsed,174                    inlineSpec = pickerSpec,175                    unlocked = unlocked,176                    hasMatches = hasLoginMatch,177                )178            )179            datasetCount++180        }181        if (datasetCount == 0) {182            callback.onSuccess(null)183        } else {184            ignoreUnfilledIds(response, parsed)185            addSaveInfo(response, parsed)186            callback.onSuccess(response.build())187        }188    }189190    /** Mark detected ids the framework should track even when we filled nothing. */191    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {192        val ids = listOfNotNull(193            parsed.identityId, parsed.passwordId, parsed.otpId,194            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,195            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,196        ) + parsed.otpDigitBoxIds197        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())198    }199200    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {201        val passwordId = parsed.passwordId ?: return202        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()203        val saveInfo = SaveInfo.Builder(204            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,205            arrayOf(passwordId),206        ).apply {207            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)208        }.build()209        builder.setSaveInfo(saveInfo)210    }211212    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =213        request.fillContexts.lastOrNull()?.structure214215    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {216        if (Build.VERSION.SDK_INT < 30) return emptyList()217        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()218    }219220    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {221        val structure = request.fillContexts.lastOrNull()?.structure222        if (structure == null || !vault.isUnlocked.value) {223            callback.onSuccess()224            return225        }226        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))227        val draft = autofillSaveDraft(parsed)228        if (draft == null) {229            callback.onSuccess()230            return231        }232        serviceScope.launch {233            try {234                withContext(Dispatchers.IO) {235                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {236                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)237                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)238                    }239                }240                callback.onSuccess()241            } catch (e: Exception) {242                callback.onFailure(e.message ?: "Peach could not save this login")243            }244        }245    }246247    override fun onDestroy() {248        serviceScope.cancel()249        super.onDestroy()250    }251252}253254/**255 * Number of inline slots reserved for system chips: one for the "Search256 * Peach" picker whenever any spec exists, plus one more for the dedicated257 * 2FA chip when eligible. v2.14.6 removed the "+ Save to Peach" create chip;258 * v2.14.8 hands its leftover reservation back to entry chips.259 */260internal fun inlineSystemChipCount(totalSpecs: Int, otpEligible: Boolean): Int =261    if (totalSpecs == 0) 0 else if (otpEligible) 2 else 1262263/** Entry/card chips get every inline slot not reserved for system chips. */264internal fun inlineChipBudget(totalSpecs: Int, otpEligible: Boolean): Int =265    (totalSpecs - inlineSystemChipCount(totalSpecs, otpEligible)).coerceAtLeast(0)266267internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {268    !unlocked -> "Unlock Peach"269    datasetCount == 0 -> "No saved logins for this site"270    else -> "Search Peach…"271}272273274/**275 * Pick the inline spec that hosts the "Search Peach…" chip, or null when276 * there isn't room. With an OTP chip eligible the picker takes the slot just277 * before it (the OTP chip owns the last spec); with a single spec the picker278 * falls back to the system menu so the 2FA chip keeps the lone inline slot279 * (v2.14.6 collision fix). Without an OTP chip the picker anchors the last280 * slot itself (v2.14.8 -- before, the stale two-slot reservation left the281 * last slot empty). The picker activity is always reachable from the282 * keyboard overflow menu, so dropping the inline chip never locks the283 * user out.284 */285internal fun <T> pickerSpecFor(specs: List<T>, otpEligible: Boolean): T? = when {286    specs.isEmpty() -> null287    otpEligible -> if (specs.size >= 2) specs[specs.size - 2] else null288    else -> specs.last()289}