PeachAutofillService.kt

12.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.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 (com.peachpasswords.BuildConfig.DEBUG) {48            android.util.Log.d(49                "PeachAutofill",50                "onFillRequest: hasCredentialField=${parsed.hasCredentialField} " +51                    "otpId=${parsed.otpId != null} origin=${parsed.origin} " +52                    "unlocked=${vault.isUnlocked.value} entryCount=${vault.getEntries().size} " +53                    "specCount=${inlineSpecs(request).size}",54            )55        }56        if (!parsed.hasCredentialField || !parsed.credentialReleaseAllowed) {57            if (com.peachpasswords.BuildConfig.DEBUG && !parsed.credentialReleaseAllowed) {58                android.util.Log.d("PeachAutofill", "refused unverified web origin")59            }60            callback.onSuccess(null)61            return62        }6364        val inlineSpecs = inlineSpecs(request)65        val privatePreview = vault.settings.value.hideAutofillPreviews66        val unlocked = vault.isUnlocked.value67        val matches = if (unlocked) {68            AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all69        } else {70            emptyList()71        }72        val response = FillResponse.Builder()73        var datasetCount = 074        var hasLoginMatch = false75        // v2.14.8 chip-strip refinement: since v2.14.6 removed the "+ Save to76        // Peach" create chip, only the picker and (when eligible) the 2FA chip77        // need reserved slots. The OTP chip keeps the last inline spec slot78        // (the framework sends specs in field-order, so that is conventionally79        // the 2FA field on a username/password/2FA form; InlinePresentationSpec80        // carries no autofillId, so the last-slot heuristic is the closest81        // mapping the framework offers), and the picker takes the slot just82        // before it -- or the last slot itself when there is no OTP chip.83        // Every remaining slot becomes an entry chip, so a wide strip fills84        // completely and scrolls, and a narrow strip still lists everything85        // in the system drop-down menu. Gating otpEligible on MATCHED entries86        // (not any vault entry) stops a TOTP-less match set from orphaning87        // the last slot. v2.14.6 single-spec collision fix holds: with one88        // spec and an OTP chip eligible, the picker drops to the system menu89        // so the 2FA chip keeps the lone slot.90        val otpEligible = parsed.otpId != null && matches.any { !it.totp.isNullOrBlank() }91        val entryChipBudget = inlineChipBudget(inlineSpecs.size, otpEligible)92        val pickerSpec = pickerSpecFor(inlineSpecs, otpEligible)93        val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null94        var entryChips = 09596        if (unlocked) {97            // No artificial cap on matching entries: each entry gets its own98            // inline spec slot up to the budget; the IME renders what fits and99            // scrolls the rest. The system drop-down menu still lists every100            // matching entry, so the user is never locked out.101            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        }120121        if (parsed.hasCardField && unlocked) {122            vault.getEntries().filter { it.isCard() }.forEach { entry ->123                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null124                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {125                    response.addDataset(it)126                    datasetCount++127                }128            }129        }130131        // A3: offer to generate a strong password on signup forms (new-password132        // field) or any password form with no matching login, sized to the site's133        // detected policy. Gated to no-match so it never appears over a known134        // login the user is about to fill.135        if (unlocked && parsed.passwordId != null &&136            (parsed.isNewPassword || !hasLoginMatch)137        ) {138            runCatching {139                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {140                    response.addDataset(it)141                    datasetCount++142                }143            }144        }145146        // Always offer the branded picker as a system chip (search / unlock /147        // no-match). The chip lives in the reserved inline slot; the drop-down148        // menu still lists it when the IME offered no specs.149        runCatching {150            response.addDataset(151                AutofillResponseBuilder.searchPeachDataset(152                    context = context,153                    parsed = parsed,154                    inlineSpec = pickerSpec,155                    unlocked = unlocked,156                    hasMatches = hasLoginMatch,157                    requestId = request.id,158                )159            )160            datasetCount++161        }162        if (datasetCount == 0) {163            callback.onSuccess(null)164        } else {165            ignoreUnfilledIds(response, parsed)166            addSaveInfo(response, parsed)167            callback.onSuccess(response.build())168        }169    }170171    /** Mark detected ids the framework should track even when we filled nothing. */172    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {173        val ids = listOfNotNull(174            parsed.identityId, parsed.passwordId, parsed.otpId,175            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,176            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,177        ) + parsed.otpDigitBoxIds178        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())179    }180181    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {182        val passwordId = parsed.passwordId ?: return183        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()184        val saveInfo = SaveInfo.Builder(185            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,186            arrayOf(passwordId),187        ).apply {188            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)189        }.build()190        builder.setSaveInfo(saveInfo)191    }192193    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =194        request.fillContexts.lastOrNull()?.structure195196    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {197        if (Build.VERSION.SDK_INT < 30) return emptyList()198        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()199    }200201    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {202        val structure = request.fillContexts.lastOrNull()?.structure203        if (structure == null || !vault.isUnlocked.value) {204            callback.onSuccess()205            return206        }207        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))208        if (!parsed.credentialReleaseAllowed) {209            callback.onSuccess()210            return211        }212        val draft = autofillSaveDraft(parsed, parsed.nativeSignerDigests.takeIf { parsed.webDomain == null })213        if (draft == null) {214            callback.onSuccess()215            return216        }217        serviceScope.launch {218            try {219                withContext(Dispatchers.IO) {220                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {221                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)222                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)223                    }224                }225                callback.onSuccess()226            } catch (e: Exception) {227                callback.onFailure(e.message ?: "Peach could not save this login")228            }229        }230    }231232    override fun onDestroy() {233        serviceScope.cancel()234        super.onDestroy()235    }236237}238239/**240 * Number of inline slots reserved for system chips: one for the "Search241 * Peach" picker whenever any spec exists, plus one more for the dedicated242 * 2FA chip when eligible. v2.14.6 removed the "+ Save to Peach" create chip;243 * v2.14.8 hands its leftover reservation back to entry chips.244 */245internal fun inlineSystemChipCount(totalSpecs: Int, otpEligible: Boolean): Int =246    if (totalSpecs == 0) 0 else if (otpEligible) 2 else 1247248/** Entry/card chips get every inline slot not reserved for system chips. */249internal fun inlineChipBudget(totalSpecs: Int, otpEligible: Boolean): Int =250    (totalSpecs - inlineSystemChipCount(totalSpecs, otpEligible)).coerceAtLeast(0)251252internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {253    !unlocked -> "Unlock Peach"254    datasetCount == 0 -> "No saved logins for this site"255    else -> "Search Peach…"256}257258259/**260 * Pick the inline spec that hosts the "Search Peach…" chip, or null when261 * there isn't room. With an OTP chip eligible the picker takes the slot just262 * before it (the OTP chip owns the last spec); with a single spec the picker263 * falls back to the system menu so the 2FA chip keeps the lone inline slot264 * (v2.14.6 collision fix). Without an OTP chip the picker anchors the last265 * slot itself (v2.14.8 -- before, the stale two-slot reservation left the266 * last slot empty). The picker activity is always reachable from the267 * keyboard overflow menu, so dropping the inline chip never locks the268 * user out.269 */270internal fun <T> pickerSpecFor(specs: List<T>, otpEligible: Boolean): T? = when {271    specs.isEmpty() -> null272    otpEligible -> if (specs.size >= 2) specs[specs.size - 2] else null273    else -> specs.last()274}