1package com.peachpasswords.autofill23import android.os.Build4import android.os.CancellationSignal5import android.service.autofill.AutofillService6import android.service.autofill.FillCallback7import android.service.autofill.FillRequest8import android.service.autofill.FillResponse9import android.service.autofill.SaveCallback10import android.service.autofill.SaveInfo11import android.service.autofill.SaveRequest12import com.peachpasswords.vault.VaultRepository13import dagger.hilt.android.AndroidEntryPoint14import kotlinx.coroutines.CoroutineScope15import kotlinx.coroutines.Dispatchers16import kotlinx.coroutines.SupervisorJob17import kotlinx.coroutines.cancel18import kotlinx.coroutines.launch19import kotlinx.coroutines.withContext20import javax.inject.Inject212223242526272829303132@AndroidEntryPoint33class PeachAutofillService : AutofillService() {3435 @Inject lateinit var vault: VaultRepository36 private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)3738 override fun onFillRequest(39 request: FillRequest,40 cancellationSignal: CancellationSignal,41 callback: FillCallback,42 ) {43 val context = applicationContext44 val structure = latestStructure(request) ?: run { callback.onSuccess(null); return }45 val parsed = AssistStructureParser.parse(structure)46 if (!parsed.hasUsableField) { callback.onSuccess(null); return }4748 49 50 51 if (parsed.webDomain != null) {52 val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName53 if (!TrustedBrowsers.isTrusted(clientPackage)) { callback.onSuccess(null); return }54 }5556 val inlineSpecs = inlineSpecs(request)57 val response = FillResponse.Builder()58 var datasetCount = 05960 if (vault.isUnlocked.value) {61 val matches = AutofillResponseBuilder62 .matchEntries(vault.getEntries(), parsed.origin)63 .all.take(MAX_DATASETS)64 matches.forEachIndexed { index, entry ->65 val spec = inlineSpecs.getOrNull(index)66 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let {67 response.addDataset(it)68 datasetCount++69 }70 }71 }7273 74 val pickerLabel = autofillPickerLabel(75 unlocked = vault.isUnlocked.value,76 datasetCount = datasetCount,77 )78 runCatching {79 response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))80 datasetCount++81 }8283 if (datasetCount == 0) {84 callback.onSuccess(null)85 } else {86 ignoreUnfilledIds(response, parsed)87 addSaveInfo(response, parsed)88 callback.onSuccess(response.build())89 }90 }9192 private fun pickerDataset(93 context: android.content.Context,94 parsed: ParsedStructure,95 label: String,96 inlineSpec: android.widget.inline.InlinePresentationSpec?,97 ): android.service.autofill.Dataset {98 val menu = AutofillResponseBuilder.simpleRow(context, label, parsed.origin ?: "")99 val builder = android.service.autofill.Dataset.Builder(menu)100 101 102 listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId).forEach { id ->103 builder.setValue(id, null, menu)104 }105 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))106 return builder.build()107 }108109 110 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {111 val ids = listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId)112 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())113 }114115 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {116 val passwordId = parsed.passwordId ?: return117 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()118 val saveInfo = SaveInfo.Builder(119 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,120 arrayOf(passwordId),121 ).apply {122 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)123 }.build()124 builder.setSaveInfo(saveInfo)125 }126127 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =128 request.fillContexts.lastOrNull()?.structure129130 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {131 if (Build.VERSION.SDK_INT < 30) return emptyList()132 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()133 }134135 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {136 val structure = request.fillContexts.lastOrNull()?.structure137 if (structure == null || !vault.isUnlocked.value) {138 callback.onSuccess()139 return140 }141 val parsed = AssistStructureParser.parse(structure)142 if (parsed.webDomain != null) {143 val clientPackage = structure.activityComponent?.packageName ?: parsed.packageName144 if (!TrustedBrowsers.isTrusted(clientPackage)) {145 callback.onSuccess()146 return147 }148 }149 val draft = autofillSaveDraft(parsed)150 if (draft == null) {151 callback.onSuccess()152 return153 }154 serviceScope.launch {155 try {156 withContext(Dispatchers.IO) {157 when (val plan = planAutofillSave(vault.getEntries(), draft)) {158 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)159 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)160 }161 }162 callback.onSuccess()163 } catch (e: Exception) {164 callback.onFailure(e.message ?: "Peach could not save this login")165 }166 }167 }168169 override fun onDestroy() {170 serviceScope.cancel()171 super.onDestroy()172 }173174 private companion object {175 const val MAX_DATASETS = 6176 }177}178179internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {180 !unlocked -> "Unlock Peach"181 datasetCount == 0 -> "No saved logins for this site"182 else -> "Search Peach…"183}