PeachAutofillService.kt

12.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.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 (!parsed.hasCredentialField) { callback.onSuccess(null); return }5051        val inlineSpecs = inlineSpecs(request)52        val privatePreview = vault.settings.value.hideAutofillPreviews53        val unlocked = vault.isUnlocked.value54        val response = FillResponse.Builder()55        var datasetCount = 056        var hasLoginMatch = false57        // Reserve the last inline slots for the system chips (picker + create)58        // so the full vault stays reachable from the keyboard row even when59        // entry chips fill every other slot. The IME scrolls horizontally for60        // overflow on wide enough strips; on narrow strips the user can still61        // reach every matching entry via the system drop-down menu.62        val systemChipCount = inlineSystemChipCount(inlineSpecs.size)63        val entryChipBudget = (inlineSpecs.size - systemChipCount).coerceAtLeast(0)64        // v2.14.6: when only one inline spec is offered by the framework65        // (common on 2FA-only pages after username/password submit), the66        // picker spec and OTP spec would both target specs[0] and the picker67        // dataset -- added after the OTP dataset -- would clobber the OTP68        // chip inline. Drop the picker to the system menu in the single-spec69        // case so the 2FA chip has the slot to itself. The picker activity70        // remains reachable via the keyboard overflow menu.71        val pickerSpec = pickerSpecFor(inlineSpecs)72        // OTP chip takes the last inline spec slot (which is conventionally73        // the 2FA field on a username/password/2FA form -- the framework74        // sends specs in field-order). InlinePresentationSpec doesn't carry75        // an autofillId so we can't pin it to the exact field; the76        // last-slot heuristic is the closest mapping the framework offers.77        // (v2.14.6) The "+ Save to Peach" create chip is gone -- the chip's78        // PendingIntent was unreliable after unlock and never reached the79        // new-entry editor. The picker activity still exposes an explicit80        // "Add new entry" action, so creation remains reachable.81        val otpEligible = parsed.otpId != null &&82            vault.getEntries().any { !it.totp.isNullOrBlank() }83        val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null84        var entryChips = 08586        if (unlocked) {87            // No artificial cap on matching entries: each entry gets its own88            // inline spec slot up to the budget; the IME renders what fits and89            // scrolls the rest. The system drop-down menu still lists every90            // matching entry, so the user is never locked out.91            val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all92            matches.forEach { entry ->93                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null94                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {95                    response.addDataset(it)96                    datasetCount++97                    hasLoginMatch = true98                }99                // Dedicated 2FA chip on the last inline spec slot -- same100                // dataset family, OTP_FILL labels (chunked code as title,101                // "2FA" subtitle). Separate affordance from the LOGIN chip so102                // the user can verify the code before it lands.103                if (!entry.totp.isNullOrBlank() && otpSpec != null) {104                    AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {105                        response.addDataset(it)106                        datasetCount++107                    }108                }109            }110            // Clipboard safety net: when ANY matched entry has a TOTP, copy111            // its freshly-generated code to the clipboard so the user can112            // long-press the 2FA field → Paste even if the OTP chip fails113            // to surface (custom labels, spec-slot mismatches, lifecycle114            // gaps). PeachClipboard.copy marks the clip IS_SENSITIVE and115            // auto-clears after 30s — never stomps a later user copy.116            matches.firstOrNull { !it.totp.isNullOrBlank() }?.let { entry ->117                TOTPGenerator.generateCode(entry.totp!!)?.let { code ->118                    PeachClipboard.copy(context, label = "Peach 2FA", value = code, hint = true)119                }120            }121        }122123        if (parsed.hasCardField && unlocked) {124            vault.getEntries().filter { it.isCard() }.forEach { entry ->125                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null126                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {127                    response.addDataset(it)128                    datasetCount++129                }130            }131        }132133        // A3: offer to generate a strong password on signup forms (new-password134        // field) or any password form with no matching login, sized to the site's135        // detected policy. Gated to no-match so it never appears over a known136        // login the user is about to fill.137        if (unlocked && parsed.passwordId != null &&138            (parsed.isNewPassword || !hasLoginMatch)139        ) {140            runCatching {141                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {142                    response.addDataset(it)143                    datasetCount++144                }145            }146        }147148        // Always offer the branded picker as a system chip (search / unlock /149        // no-match) and the "+ Save to Peach" create chip when the vault is150        // unlocked. Both chips live in the reserved last inline slots; the151        // drop-down menu still lists them when the IME offered no specs.152        runCatching {153            response.addDataset(154                AutofillResponseBuilder.searchPeachDataset(155                    context = context,156                    parsed = parsed,157                    inlineSpec = pickerSpec,158                    unlocked = unlocked,159                    hasMatches = hasLoginMatch,160                )161            )162            datasetCount++163        }164        if (datasetCount == 0) {165            callback.onSuccess(null)166        } else {167            ignoreUnfilledIds(response, parsed)168            addSaveInfo(response, parsed)169            callback.onSuccess(response.build())170        }171    }172173    /** Mark detected ids the framework should track even when we filled nothing. */174    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {175        val ids = listOfNotNull(176            parsed.identityId, parsed.passwordId, parsed.otpId,177            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,178            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,179        ) + parsed.otpDigitBoxIds180        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())181    }182183    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {184        val passwordId = parsed.passwordId ?: return185        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()186        val saveInfo = SaveInfo.Builder(187            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,188            arrayOf(passwordId),189        ).apply {190            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)191        }.build()192        builder.setSaveInfo(saveInfo)193    }194195    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =196        request.fillContexts.lastOrNull()?.structure197198    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {199        if (Build.VERSION.SDK_INT < 30) return emptyList()200        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()201    }202203    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {204        val structure = request.fillContexts.lastOrNull()?.structure205        if (structure == null || !vault.isUnlocked.value) {206            callback.onSuccess()207            return208        }209        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))210        val draft = autofillSaveDraft(parsed)211        if (draft == null) {212            callback.onSuccess()213            return214        }215        serviceScope.launch {216            try {217                withContext(Dispatchers.IO) {218                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {219                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)220                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)221                    }222                }223                callback.onSuccess()224            } catch (e: Exception) {225                callback.onFailure(e.message ?: "Peach could not save this login")226            }227        }228    }229230    override fun onDestroy() {231        serviceScope.cancel()232        super.onDestroy()233    }234235}236237/**238 * Number of inline slots reserved for system chips (Search Peach / + Save to239 * Peach). With 2+ available slots we keep one each; with a single slot the240 * picker wins (create gets the drop-down row but no chip), since unlocking the241 * picker is the more common next action than creating a new entry.242 */243internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0244245/**246 * Entry/card chips get every inline slot not reserved for system chips.247 * Preserved for the existing unit test — the live service uses248 * [inlineSystemChipCount] directly.249 */250internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).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 * v2.14.6: pick the inline spec that hosts the "Search Peach…" chip, or261 * null when there isn't room. With 2+ specs the picker takes the slot just262 * before the OTP chip (which lives at the last spec); with a single spec263 * the picker falls back to the system menu so the OTP chip keeps the lone264 * inline slot. The picker activity is always reachable from the keyboard265 * overflow menu, so dropping the inline chip in the single-spec case266 * doesn't lock the user out.267 */268internal fun <T> pickerSpecFor(specs: List<T>): T? =269    if (specs.size >= 2) specs[specs.size - 2] else null