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 response = FillResponse.Builder() var datasetCount = 0 var hasLoginMatch = false 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++ hasLoginMatch = true } } } if (parsed.hasCardField && vault.isUnlocked.value) { vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEachIndexed { index, entry -> val spec = inlineSpecs.getOrNull(datasetCount + index) AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec)?.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 (vault.isUnlocked.value && parsed.passwordId != null && (parsed.isNewPassword || !hasLoginMatch) ) { runCatching { AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let { response.addDataset(it) datasetCount++ } } } // Always offer the branded picker as the last row (search / unlock / no-match). val pickerLabel = 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) 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 = 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, parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId, parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId, ) + parsed.otpDigitBoxIds).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, 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() } 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…" }