package com.peachpasswords.autofill import android.os.Build import android.os.CancellationSignal import android.service.autofill.AutofillService import android.service.autofill.FillCallback import android.service.autofill.FillRequest import android.service.autofill.FillResponse import android.service.autofill.SaveCallback import android.service.autofill.SaveInfo import android.service.autofill.SaveRequest import com.peachpasswords.vault.VaultRepository import com.peachpasswords.shared.PeachClipboard import com.peachpasswords.vault.TOTPGenerator import com.peachpasswords.vault.isCard import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject /** * System autofill provider. Parses the focused form, then either offers * matching vault entries (inline chip + drop-down rows) or a single branded * "Search Peach…" entry that opens [AutofillEntryPickerActivity]. * * The vault is a process singleton, so when the app has the vault unlocked the * service fills directly; when locked it offers only the picker, which routes * the user to unlock. Native save requests add or update login entries only * when the vault is already unlocked and the same strict origin policy passes. */ @AndroidEntryPoint class PeachAutofillService : AutofillService() { @Inject lateinit var vault: VaultRepository private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) override fun onFillRequest( request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback, ) { val context = applicationContext val structure = latestStructure(request) ?: run { callback.onSuccess(null); return } val parsed = resolveAutofillOrigin(context, AssistStructureParser.parse(structure)) if (!parsed.hasCredentialField) { callback.onSuccess(null); return } val inlineSpecs = inlineSpecs(request) val privatePreview = vault.settings.value.hideAutofillPreviews val unlocked = vault.isUnlocked.value val response = FillResponse.Builder() var datasetCount = 0 var hasLoginMatch = false // Reserve the last inline slots for the system chips (picker + create) // so the full vault stays reachable from the keyboard row even when // entry chips fill every other slot. The IME scrolls horizontally for // overflow on wide enough strips; on narrow strips the user can still // reach every matching entry via the system drop-down menu. val systemChipCount = inlineSystemChipCount(inlineSpecs.size) val entryChipBudget = (inlineSpecs.size - systemChipCount).coerceAtLeast(0) // v2.14.6: when only one inline spec is offered by the framework // (common on 2FA-only pages after username/password submit), the // picker spec and OTP spec would both target specs[0] and the picker // dataset -- added after the OTP dataset -- would clobber the OTP // chip inline. Drop the picker to the system menu in the single-spec // case so the 2FA chip has the slot to itself. The picker activity // remains reachable via the keyboard overflow menu. val pickerSpec = pickerSpecFor(inlineSpecs) // OTP chip takes the last inline spec slot (which is conventionally // the 2FA field on a username/password/2FA form -- the framework // sends specs in field-order). InlinePresentationSpec doesn't carry // an autofillId so we can't pin it to the exact field; the // last-slot heuristic is the closest mapping the framework offers. // (v2.14.6) The "+ Save to Peach" create chip is gone -- the chip's // PendingIntent was unreliable after unlock and never reached the // new-entry editor. The picker activity still exposes an explicit // "Add new entry" action, so creation remains reachable. val otpEligible = parsed.otpId != null && vault.getEntries().any { !it.totp.isNullOrBlank() } val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null var entryChips = 0 if (unlocked) { // No artificial cap on matching entries: each entry gets its own // inline spec slot up to the budget; the IME renders what fits and // scrolls the rest. The system drop-down menu still lists every // matching entry, so the user is never locked out. val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all matches.forEach { entry -> val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let { response.addDataset(it) datasetCount++ hasLoginMatch = true } // Dedicated 2FA chip on the last inline spec slot -- same // dataset family, OTP_FILL labels (chunked code as title, // "2FA" subtitle). Separate affordance from the LOGIN chip so // the user can verify the code before it lands. if (!entry.totp.isNullOrBlank() && otpSpec != null) { AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let { response.addDataset(it) datasetCount++ } } } // Clipboard safety net: when ANY matched entry has a TOTP, copy // its freshly-generated code to the clipboard so the user can // long-press the 2FA field → Paste even if the OTP chip fails // to surface (custom labels, spec-slot mismatches, lifecycle // gaps). PeachClipboard.copy marks the clip IS_SENSITIVE and // auto-clears after 30s — never stomps a later user copy. matches.firstOrNull { !it.totp.isNullOrBlank() }?.let { entry -> TOTPGenerator.generateCode(entry.totp!!)?.let { code -> PeachClipboard.copy(context, label = "Peach 2FA", value = code, hint = true) } } } if (parsed.hasCardField && unlocked) { vault.getEntries().filter { it.isCard() }.forEach { entry -> val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let { response.addDataset(it) datasetCount++ } } } // A3: offer to generate a strong password on signup forms (new-password // field) or any password form with no matching login, sized to the site's // detected policy. Gated to no-match so it never appears over a known // login the user is about to fill. if (unlocked && parsed.passwordId != null && (parsed.isNewPassword || !hasLoginMatch) ) { runCatching { AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let { response.addDataset(it) datasetCount++ } } } // Always offer the branded picker as a system chip (search / unlock / // no-match) and the "+ Save to Peach" create chip when the vault is // unlocked. Both chips live in the reserved last inline slots; the // drop-down menu still lists them when the IME offered no specs. runCatching { response.addDataset( AutofillResponseBuilder.searchPeachDataset( context = context, parsed = parsed, inlineSpec = pickerSpec, unlocked = unlocked, hasMatches = hasLoginMatch, ) ) datasetCount++ } if (datasetCount == 0) { callback.onSuccess(null) } else { ignoreUnfilledIds(response, parsed) addSaveInfo(response, parsed) callback.onSuccess(response.build()) } } /** Mark detected ids the framework should track even when we filled nothing. */ private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) { val ids = listOfNotNull( parsed.identityId, parsed.passwordId, parsed.otpId, parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId, parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId, ) + parsed.otpDigitBoxIds if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray()) } private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) { val passwordId = parsed.passwordId ?: return val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray() val saveInfo = SaveInfo.Builder( SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD, arrayOf(passwordId), ).apply { if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds) }.build() builder.setSaveInfo(saveInfo) } private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? = request.fillContexts.lastOrNull()?.structure private fun inlineSpecs(request: FillRequest): List { if (Build.VERSION.SDK_INT < 30) return emptyList() return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() } override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { val structure = request.fillContexts.lastOrNull()?.structure if (structure == null || !vault.isUnlocked.value) { callback.onSuccess() return } val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure)) val draft = autofillSaveDraft(parsed) if (draft == null) { callback.onSuccess() return } serviceScope.launch { try { withContext(Dispatchers.IO) { when (val plan = planAutofillSave(vault.getEntries(), draft)) { is AutofillSavePlan.Add -> vault.addEntry(plan.entry) is AutofillSavePlan.Update -> vault.updateEntry(plan.entry) } } callback.onSuccess() } catch (e: Exception) { callback.onFailure(e.message ?: "Peach could not save this login") } } } override fun onDestroy() { serviceScope.cancel() super.onDestroy() } } /** * Number of inline slots reserved for system chips (Search Peach / + Save to * Peach). With 2+ available slots we keep one each; with a single slot the * picker wins (create gets the drop-down row but no chip), since unlocking the * picker is the more common next action than creating a new entry. */ internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0 /** * Entry/card chips get every inline slot not reserved for system chips. * Preserved for the existing unit test — the live service uses * [inlineSystemChipCount] directly. */ internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).coerceAtLeast(0) internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when { !unlocked -> "Unlock Peach" datasetCount == 0 -> "No saved logins for this site" else -> "Search Peach…" } /** * v2.14.6: pick the inline spec that hosts the "Search Peach…" chip, or * null when there isn't room. With 2+ specs the picker takes the slot just * before the OTP chip (which lives at the last spec); with a single spec * the picker falls back to the system menu so the OTP chip keeps the lone * inline slot. The picker activity is always reachable from the keyboard * overflow menu, so dropping the inline chip in the single-spec case * doesn't lock the user out. */ internal fun pickerSpecFor(specs: List): T? = if (specs.size >= 2) specs[specs.size - 2] else null