PeachAutofillService.kt

13.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.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 response = FillResponse.Builder()64        var datasetCount = 065        var hasLoginMatch = false66        // Reserve the last inline slots for the system chips (picker + create)67        // so the full vault stays reachable from the keyboard row even when68        // entry chips fill every other slot. The IME scrolls horizontally for69        // overflow on wide enough strips; on narrow strips the user can still70        // reach every matching entry via the system drop-down menu.71        val systemChipCount = inlineSystemChipCount(inlineSpecs.size)72        val entryChipBudget = (inlineSpecs.size - systemChipCount).coerceAtLeast(0)73        // v2.14.6: when only one inline spec is offered by the framework74        // (common on 2FA-only pages after username/password submit), the75        // picker spec and OTP spec would both target specs[0] and the picker76        // dataset -- added after the OTP dataset -- would clobber the OTP77        // chip inline. Drop the picker to the system menu in the single-spec78        // case so the 2FA chip has the slot to itself. The picker activity79        // remains reachable via the keyboard overflow menu.80        val pickerSpec = pickerSpecFor(inlineSpecs)81        // OTP chip takes the last inline spec slot (which is conventionally82        // the 2FA field on a username/password/2FA form -- the framework83        // sends specs in field-order). InlinePresentationSpec doesn't carry84        // an autofillId so we can't pin it to the exact field; the85        // last-slot heuristic is the closest mapping the framework offers.86        // (v2.14.6) The "+ Save to Peach" create chip is gone -- the chip's87        // PendingIntent was unreliable after unlock and never reached the88        // new-entry editor. The picker activity still exposes an explicit89        // "Add new entry" action, so creation remains reachable.90        val otpEligible = parsed.otpId != null &&91            vault.getEntries().any { !it.totp.isNullOrBlank() }92        val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null93        var entryChips = 09495        if (unlocked) {96            // No artificial cap on matching entries: each entry gets its own97            // inline spec slot up to the budget; the IME renders what fits and98            // scrolls the rest. The system drop-down menu still lists every99            // matching entry, so the user is never locked out.100            val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all101            matches.forEach { entry ->102                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null103                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {104                    response.addDataset(it)105                    datasetCount++106                    hasLoginMatch = true107                }108                // Dedicated 2FA chip on the last inline spec slot -- same109                // dataset family, OTP_FILL labels (chunked code as title,110                // "2FA" subtitle). Separate affordance from the LOGIN chip so111                // the user can verify the code before it lands.112                if (!entry.totp.isNullOrBlank() && otpSpec != null) {113                    AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {114                        response.addDataset(it)115                        datasetCount++116                    }117                }118            }119            // Clipboard safety net: when ANY matched entry has a TOTP, copy120            // its freshly-generated code to the clipboard so the user can121            // long-press the 2FA field → Paste even if the OTP chip fails122            // to surface (custom labels, spec-slot mismatches, lifecycle123            // gaps). PeachClipboard.copy marks the clip IS_SENSITIVE and124            // auto-clears after 30s — never stomps a later user copy.125            // Wrapped in runCatching so a clipboard-service hiccup can126            // never crash the FillRequest handler (which would leave the127            // user with no chips at all).128            runCatching {129                matches.firstOrNull { !it.totp.isNullOrBlank() }?.let { entry ->130                    TOTPGenerator.generateCode(entry.totp!!)?.let { code ->131                        PeachClipboard.copy(context, label = "Peach 2FA", value = code, hint = true)132                        if (com.peachpasswords.BuildConfig.DEBUG) {133                            android.util.Log.d(134                                "PeachAutofill",135                                "2FA clipboard copy fired (entry=" + entry.title + ", code=" + code + ")",136                            )137                        }138                    }139                }140            }.onFailure { t ->141                android.util.Log.w("PeachAutofill", "2FA clipboard copy failed", t)142            }143        }144145        if (parsed.hasCardField && unlocked) {146            vault.getEntries().filter { it.isCard() }.forEach { entry ->147                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null148                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {149                    response.addDataset(it)150                    datasetCount++151                }152            }153        }154155        // A3: offer to generate a strong password on signup forms (new-password156        // field) or any password form with no matching login, sized to the site's157        // detected policy. Gated to no-match so it never appears over a known158        // login the user is about to fill.159        if (unlocked && parsed.passwordId != null &&160            (parsed.isNewPassword || !hasLoginMatch)161        ) {162            runCatching {163                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {164                    response.addDataset(it)165                    datasetCount++166                }167            }168        }169170        // Always offer the branded picker as a system chip (search / unlock /171        // no-match) and the "+ Save to Peach" create chip when the vault is172        // unlocked. Both chips live in the reserved last inline slots; the173        // drop-down menu still lists them when the IME offered no specs.174        runCatching {175            response.addDataset(176                AutofillResponseBuilder.searchPeachDataset(177                    context = context,178                    parsed = parsed,179                    inlineSpec = pickerSpec,180                    unlocked = unlocked,181                    hasMatches = hasLoginMatch,182                )183            )184            datasetCount++185        }186        if (datasetCount == 0) {187            callback.onSuccess(null)188        } else {189            ignoreUnfilledIds(response, parsed)190            addSaveInfo(response, parsed)191            callback.onSuccess(response.build())192        }193    }194195    /** Mark detected ids the framework should track even when we filled nothing. */196    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {197        val ids = listOfNotNull(198            parsed.identityId, parsed.passwordId, parsed.otpId,199            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,200            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,201        ) + parsed.otpDigitBoxIds202        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())203    }204205    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {206        val passwordId = parsed.passwordId ?: return207        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()208        val saveInfo = SaveInfo.Builder(209            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,210            arrayOf(passwordId),211        ).apply {212            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)213        }.build()214        builder.setSaveInfo(saveInfo)215    }216217    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =218        request.fillContexts.lastOrNull()?.structure219220    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {221        if (Build.VERSION.SDK_INT < 30) return emptyList()222        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()223    }224225    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {226        val structure = request.fillContexts.lastOrNull()?.structure227        if (structure == null || !vault.isUnlocked.value) {228            callback.onSuccess()229            return230        }231        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))232        val draft = autofillSaveDraft(parsed)233        if (draft == null) {234            callback.onSuccess()235            return236        }237        serviceScope.launch {238            try {239                withContext(Dispatchers.IO) {240                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {241                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)242                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)243                    }244                }245                callback.onSuccess()246            } catch (e: Exception) {247                callback.onFailure(e.message ?: "Peach could not save this login")248            }249        }250    }251252    override fun onDestroy() {253        serviceScope.cancel()254        super.onDestroy()255    }256257}258259/**260 * Number of inline slots reserved for system chips (Search Peach / + Save to261 * Peach). With 2+ available slots we keep one each; with a single slot the262 * picker wins (create gets the drop-down row but no chip), since unlocking the263 * picker is the more common next action than creating a new entry.264 */265internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0266267/**268 * Entry/card chips get every inline slot not reserved for system chips.269 * Preserved for the existing unit test — the live service uses270 * [inlineSystemChipCount] directly.271 */272internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).coerceAtLeast(0)273274internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {275    !unlocked -> "Unlock Peach"276    datasetCount == 0 -> "No saved logins for this site"277    else -> "Search Peach…"278}279280281/**282 * v2.14.6: pick the inline spec that hosts the "Search Peach…" chip, or283 * null when there isn't room. With 2+ specs the picker takes the slot just284 * before the OTP chip (which lives at the last spec); with a single spec285 * the picker falls back to the system menu so the OTP chip keeps the lone286 * inline slot. The picker activity is always reachable from the keyboard287 * overflow menu, so dropping the inline chip in the single-spec case288 * doesn't lock the user out.289 */290internal fun <T> pickerSpecFor(specs: List<T>): T? =291    if (specs.size >= 2) specs[specs.size - 2] else null