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 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 = authenticateAutofillOrigin(context, AssistStructureParser.parse(structure)) if (!parsed.hasUsableField) { 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) .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 = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN && vault.isUnlocked.value) { "Verify site in Peach…" } else { autofillPickerLabel( unlocked = vault.isUnlocked.value, datasetCount = datasetCount, ) } runCatching { response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount))) datasetCount++ } if (datasetCount == 0) { callback.onSuccess(null) } else { ignoreUnfilledIds(response, parsed) if (parsed.originTrust == AutofillOriginTrust.TRUSTED_WEB) { addSaveInfo(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 subtitle = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) { "Origin not verified" } else { parsed.origin.orEmpty() } val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle) 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 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 = authenticateAutofillOrigin(applicationContext, AssistStructureParser.parse(structure)) if (parsed.originTrust != AutofillOriginTrust.TRUSTED_WEB) { 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() } private companion object { const val MAX_DATASETS = 6 } } internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when { !unlocked -> "Unlock Peach" datasetCount == 0 -> "No saved logins for this site" else -> "Search Peach…" }