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 = authenticateAutofillOrigin(context, AssistStructureParser.parse(structure))46 if (!parsed.hasUsableField) { callback.onSuccess(null); return }4748 val inlineSpecs = inlineSpecs(request)49 val response = FillResponse.Builder()50 var datasetCount = 05152 if (vault.isUnlocked.value) {53 val matches = AutofillResponseBuilder54 .matchEntries(vault.getEntries(), parsed)55 .all.take(MAX_DATASETS)56 matches.forEachIndexed { index, entry ->57 val spec = inlineSpecs.getOrNull(index)58 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let {59 response.addDataset(it)60 datasetCount++61 }62 }63 }6465 66 val pickerLabel = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN && vault.isUnlocked.value) {67 "Verify site in Peach…"68 } else {69 autofillPickerLabel(70 unlocked = vault.isUnlocked.value,71 datasetCount = datasetCount,72 )73 }74 runCatching {75 response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))76 datasetCount++77 }7879 if (datasetCount == 0) {80 callback.onSuccess(null)81 } else {82 ignoreUnfilledIds(response, parsed)83 if (parsed.originTrust == AutofillOriginTrust.TRUSTED_WEB) {84 addSaveInfo(response, parsed)85 }86 callback.onSuccess(response.build())87 }88 }8990 private fun pickerDataset(91 context: android.content.Context,92 parsed: ParsedStructure,93 label: String,94 inlineSpec: android.widget.inline.InlinePresentationSpec?,95 ): android.service.autofill.Dataset {96 val subtitle = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {97 "Origin not verified"98 } else {99 parsed.origin.orEmpty()100 }101 val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)102 val builder = android.service.autofill.Dataset.Builder(menu)103 104 105 listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId).forEach { id ->106 builder.setValue(id, null, menu)107 }108 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))109 return builder.build()110 }111112 113 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {114 val ids = listOfNotNull(parsed.identityId, parsed.passwordId, parsed.otpId)115 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())116 }117118 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {119 val passwordId = parsed.passwordId ?: return120 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()121 val saveInfo = SaveInfo.Builder(122 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,123 arrayOf(passwordId),124 ).apply {125 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)126 }.build()127 builder.setSaveInfo(saveInfo)128 }129130 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =131 request.fillContexts.lastOrNull()?.structure132133 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {134 if (Build.VERSION.SDK_INT < 30) return emptyList()135 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()136 }137138 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {139 val structure = request.fillContexts.lastOrNull()?.structure140 if (structure == null || !vault.isUnlocked.value) {141 callback.onSuccess()142 return143 }144 val parsed = authenticateAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))145 if (parsed.originTrust != AutofillOriginTrust.TRUSTED_WEB) {146 callback.onSuccess()147 return148 }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}