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.SaveRequest import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.AndroidEntryPoint 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. The save flow is intentionally a no-op for now — Peach * adds entries through its own UI, not the system Save dialog. */ @AndroidEntryPoint class PeachAutofillService : AutofillService() { @Inject lateinit var vault: VaultRepository override fun onFillRequest( request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback, ) { val context = applicationContext val structure = latestStructure(request) ?: run { callback.onSuccess(null); return } val parsed = AssistStructureParser.parse(structure) if (!parsed.hasUsableField) { callback.onSuccess(null); return } // MF-7: a web origin (webDomain) is only trustworthy from a known browser. // A non-browser app claiming a web domain is trying to elicit credentials it // does not own — refuse to offer anything at all. if (parsed.webDomain != null) { val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName if (!TrustedBrowsers.isTrusted(clientPackage)) { callback.onSuccess(null); return } } val inlineSpecs = inlineSpecs(request) val response = FillResponse.Builder() var datasetCount = 0 if (vault.isUnlocked.value) { val matches = AutofillResponseBuilder .matchEntries(vault.getEntries(), parsed.origin) .all.take(MAX_DATASETS) matches.forEachIndexed { index, entry -> val spec = inlineSpecs.getOrNull(index) AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let { response.addDataset(it) datasetCount++ } } } // Always offer the branded picker as the last row (search / unlock / no-match). val pickerLabel = when { !vault.isUnlocked.value -> "Unlock Peach" datasetCount == 0 -> "No saved logins for this site" else -> "Search Peach…" } runCatching { response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount))) datasetCount++ } if (datasetCount == 0) { callback.onSuccess(null) } else { ignoreUnfilledIds(response, parsed) callback.onSuccess(response.build()) } } private fun pickerDataset( context: android.content.Context, parsed: ParsedStructure, label: String, inlineSpec: android.widget.inline.InlinePresentationSpec?, ): android.service.autofill.Dataset { val menu = AutofillResponseBuilder.simpleRow(context, label, parsed.origin ?: "") val builder = android.service.autofill.Dataset.Builder(menu) // A placeholder value per field keeps the dataset valid; the real values // are supplied by the picker via EXTRA_AUTHENTICATION_RESULT. listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId).forEach { id -> builder.setValue(id, null, menu) } builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed)) return builder.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) if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray()) } 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) { // Peach captures new logins through its own add-entry flow. callback.onSuccess() } private companion object { const val MAX_DATASETS = 6 } }