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.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) val pickerSpec = if (inlineSpecs.size >= 2) { inlineSpecs[inlineSpecs.size - 2] } else { inlineSpecs.lastOrNull() } val createSpec = inlineSpecs.lastOrNull() // When the form has a detected 2FA field AND a matching TOTP-enabled // entry exists, the 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). The create chip drops to // the system drop-down only; the user can still reach it via the // menu. InlinePresentationSpec doesn't carry an autofillId, so we // can't pin the chip to the exact field -- the last-slot heuristic // is the closest mapping the framework offers. val otpEligible = parsed.otpId != null && vault.getEntries().any { !it.totp.isNullOrBlank() } val otpSpec = if (otpEligible) createSpec else null val otpTakesCreateSlot = otpEligible && createSpec != 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++ } } } } 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++ } // Create chip moves to the system drop-down only when the OTP chip // took its inline spec slot -- the 2FA chip is the higher-value // affordance and the create path is still one tap away via the menu. AutofillResponseBuilder.createNewEntryDataset( context = context, parsed = parsed, inlineSpec = if (otpTakesCreateSlot) null else createSpec, unlocked = unlocked, origin = parsed.origin, )?.let { response.addDataset(it) 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…" }