PeachAutofillService.kt

11.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 (!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        // When the form has a detected 2FA field AND a matching TOTP-enabled69        // entry exists, the OTP chip takes the last inline spec slot (which70        // is conventionally the 2FA field on a username/password/2FA form --71        // the framework sends specs in field-order). The create chip drops to72        // the system drop-down only; the user can still reach it via the73        // menu. InlinePresentationSpec doesn't carry an autofillId, so we74        // can't pin the chip to the exact field -- the last-slot heuristic75        // is the closest mapping the framework offers.76        val otpEligible = parsed.otpId != null &&77            vault.getEntries().any { !it.totp.isNullOrBlank() }78        val otpSpec = if (otpEligible) createSpec else null79        val otpTakesCreateSlot = otpEligible && createSpec != null80        var entryChips = 08182        if (unlocked) {83            // No artificial cap on matching entries: each entry gets its own84            // inline spec slot up to the budget; the IME renders what fits and85            // scrolls the rest. The system drop-down menu still lists every86            // matching entry, so the user is never locked out.87            val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all88            matches.forEach { entry ->89                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null90                AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {91                    response.addDataset(it)92                    datasetCount++93                    hasLoginMatch = true94                }95                // Dedicated 2FA chip on the last inline spec slot -- same96                // dataset family, OTP_FILL labels (chunked code as title,97                // "2FA" subtitle). Separate affordance from the LOGIN chip so98                // the user can verify the code before it lands.99                if (!entry.totp.isNullOrBlank() && otpSpec != null) {100                    AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {101                        response.addDataset(it)102                        datasetCount++103                    }104                }105            }106        }107108        if (parsed.hasCardField && unlocked) {109            vault.getEntries().filter { it.isCard() }.forEach { entry ->110                val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null111                AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {112                    response.addDataset(it)113                    datasetCount++114                }115            }116        }117118        // A3: offer to generate a strong password on signup forms (new-password119        // field) or any password form with no matching login, sized to the site's120        // detected policy. Gated to no-match so it never appears over a known121        // login the user is about to fill.122        if (unlocked && parsed.passwordId != null &&123            (parsed.isNewPassword || !hasLoginMatch)124        ) {125            runCatching {126                AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {127                    response.addDataset(it)128                    datasetCount++129                }130            }131        }132133        // Always offer the branded picker as a system chip (search / unlock /134        // no-match) and the "+ Save to Peach" create chip when the vault is135        // unlocked. Both chips live in the reserved last inline slots; the136        // drop-down menu still lists them when the IME offered no specs.137        runCatching {138            response.addDataset(139                AutofillResponseBuilder.searchPeachDataset(140                    context = context,141                    parsed = parsed,142                    inlineSpec = pickerSpec,143                    unlocked = unlocked,144                    hasMatches = hasLoginMatch,145                )146            )147            datasetCount++148        }149        // Create chip moves to the system drop-down only when the OTP chip150        // took its inline spec slot -- the 2FA chip is the higher-value151        // affordance and the create path is still one tap away via the menu.152        AutofillResponseBuilder.createNewEntryDataset(153            context = context,154            parsed = parsed,155            inlineSpec = if (otpTakesCreateSlot) null else createSpec,156            unlocked = unlocked,157            origin = parsed.origin,158        )?.let {159            response.addDataset(it)160            datasetCount++161        }162163        if (datasetCount == 0) {164            callback.onSuccess(null)165        } else {166            ignoreUnfilledIds(response, parsed)167            addSaveInfo(response, parsed)168            callback.onSuccess(response.build())169        }170    }171172    /** Mark detected ids the framework should track even when we filled nothing. */173    private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {174        val ids = listOfNotNull(175            parsed.identityId, parsed.passwordId, parsed.otpId,176            parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,177            parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,178        ) + parsed.otpDigitBoxIds179        if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())180    }181182    private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {183        val passwordId = parsed.passwordId ?: return184        val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()185        val saveInfo = SaveInfo.Builder(186            SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,187            arrayOf(passwordId),188        ).apply {189            if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)190        }.build()191        builder.setSaveInfo(saveInfo)192    }193194    private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =195        request.fillContexts.lastOrNull()?.structure196197    private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {198        if (Build.VERSION.SDK_INT < 30) return emptyList()199        return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()200    }201202    override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {203        val structure = request.fillContexts.lastOrNull()?.structure204        if (structure == null || !vault.isUnlocked.value) {205            callback.onSuccess()206            return207        }208        val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))209        val draft = autofillSaveDraft(parsed)210        if (draft == null) {211            callback.onSuccess()212            return213        }214        serviceScope.launch {215            try {216                withContext(Dispatchers.IO) {217                    when (val plan = planAutofillSave(vault.getEntries(), draft)) {218                        is AutofillSavePlan.Add -> vault.addEntry(plan.entry)219                        is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)220                    }221                }222                callback.onSuccess()223            } catch (e: Exception) {224                callback.onFailure(e.message ?: "Peach could not save this login")225            }226        }227    }228229    override fun onDestroy() {230        serviceScope.cancel()231        super.onDestroy()232    }233234}235236/**237 * Number of inline slots reserved for system chips (Search Peach / + Save to238 * Peach). With 2+ available slots we keep one each; with a single slot the239 * picker wins (create gets the drop-down row but no chip), since unlocking the240 * picker is the more common next action than creating a new entry.241 */242internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0243244/**245 * Entry/card chips get every inline slot not reserved for system chips.246 * Preserved for the existing unit test — the live service uses247 * [inlineSystemChipCount] directly.248 */249internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).coerceAtLeast(0)250251internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {252    !unlocked -> "Unlock Peach"253    datasetCount == 0 -> "No saved logins for this site"254    else -> "Search Peach…"255}