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 (com.peachpasswords.BuildConfig.DEBUG) { android.util.Log.d( "PeachAutofill", "onFillRequest: hasCredentialField=${parsed.hasCredentialField} " + "otpId=${parsed.otpId != null} origin=${parsed.origin} " + "unlocked=${vault.isUnlocked.value} entryCount=${vault.getEntries().size} " + "specCount=${inlineSpecs(request).size}", ) } if (!parsed.hasCredentialField || !parsed.credentialReleaseAllowed) { if (com.peachpasswords.BuildConfig.DEBUG && !parsed.credentialReleaseAllowed) { android.util.Log.d("PeachAutofill", "refused unverified web origin") } callback.onSuccess(null) return } val inlineSpecs = inlineSpecs(request) val privatePreview = vault.settings.value.hideAutofillPreviews val unlocked = vault.isUnlocked.value val matches = if (unlocked) { AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all } else { emptyList() } val response = FillResponse.Builder() var datasetCount = 0 var hasLoginMatch = false // v2.14.8 chip-strip refinement: since v2.14.6 removed the "+ Save to // Peach" create chip, only the picker and (when eligible) the 2FA chip // need reserved slots. The OTP chip keeps the last inline spec slot // (the framework sends specs in field-order, so that is conventionally // the 2FA field on a username/password/2FA form; InlinePresentationSpec // carries no autofillId, so the last-slot heuristic is the closest // mapping the framework offers), and the picker takes the slot just // before it -- or the last slot itself when there is no OTP chip. // Every remaining slot becomes an entry chip, so a wide strip fills // completely and scrolls, and a narrow strip still lists everything // in the system drop-down menu. Gating otpEligible on MATCHED entries // (not any vault entry) stops a TOTP-less match set from orphaning // the last slot. v2.14.6 single-spec collision fix holds: with one // spec and an OTP chip eligible, the picker drops to the system menu // so the 2FA chip keeps the lone slot. val otpEligible = parsed.otpId != null && matches.any { !it.totp.isNullOrBlank() } val entryChipBudget = inlineChipBudget(inlineSpecs.size, otpEligible) val pickerSpec = pickerSpecFor(inlineSpecs, otpEligible) 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. 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). The chip lives in the reserved inline slot; the drop-down // menu still lists it 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)) if (!parsed.credentialReleaseAllowed) { callback.onSuccess() return } 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: one for the "Search * Peach" picker whenever any spec exists, plus one more for the dedicated * 2FA chip when eligible. v2.14.6 removed the "+ Save to Peach" create chip; * v2.14.8 hands its leftover reservation back to entry chips. */ internal fun inlineSystemChipCount(totalSpecs: Int, otpEligible: Boolean): Int = if (totalSpecs == 0) 0 else if (otpEligible) 2 else 1 /** Entry/card chips get every inline slot not reserved for system chips. */ internal fun inlineChipBudget(totalSpecs: Int, otpEligible: Boolean): Int = (totalSpecs - inlineSystemChipCount(totalSpecs, otpEligible)).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…" } /** * Pick the inline spec that hosts the "Search Peach…" chip, or null when * there isn't room. With an OTP chip eligible the picker takes the slot just * before it (the OTP chip owns the last spec); with a single spec the picker * falls back to the system menu so the 2FA chip keeps the lone inline slot * (v2.14.6 collision fix). Without an OTP chip the picker anchors the last * slot itself (v2.14.8 -- before, the stale two-slot reservation left the * last slot empty). The picker activity is always reachable from the * keyboard overflow menu, so dropping the inline chip never locks the * user out. */ internal fun pickerSpecFor(specs: List, otpEligible: Boolean): T? = when { specs.isEmpty() -> null otpEligible -> if (specs.size >= 2) specs[specs.size - 2] else null else -> specs.last() }