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 com.peachpasswords.vault.isCard14import dagger.hilt.android.AndroidEntryPoint15import kotlinx.coroutines.CoroutineScope16import kotlinx.coroutines.Dispatchers17import kotlinx.coroutines.SupervisorJob18import kotlinx.coroutines.cancel19import kotlinx.coroutines.launch20import kotlinx.coroutines.withContext21import javax.inject.Inject222324252627282930313233@AndroidEntryPoint34class PeachAutofillService : AutofillService() {3536 @Inject lateinit var vault: VaultRepository37 private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)3839 override fun onFillRequest(40 request: FillRequest,41 cancellationSignal: CancellationSignal,42 callback: FillCallback,43 ) {44 val context = applicationContext45 val structure = latestStructure(request) ?: run { callback.onSuccess(null); return }46 val parsed = resolveAutofillOrigin(context, AssistStructureParser.parse(structure))47 if (!parsed.hasCredentialField) { callback.onSuccess(null); return }4849 val inlineSpecs = inlineSpecs(request)50 val response = FillResponse.Builder()51 var datasetCount = 052 var hasLoginMatch = false5354 if (vault.isUnlocked.value) {55 val matches = AutofillResponseBuilder56 .matchEntries(vault.getEntries(), parsed)57 .all.take(MAX_DATASETS)58 matches.forEachIndexed { index, entry ->59 val spec = inlineSpecs.getOrNull(index)60 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let {61 response.addDataset(it)62 datasetCount++63 hasLoginMatch = true64 }65 }66 }6768 if (parsed.hasCardField && vault.isUnlocked.value) {69 vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEachIndexed { index, entry ->70 val spec = inlineSpecs.getOrNull(datasetCount + index)71 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec)?.let {72 response.addDataset(it)73 datasetCount++74 }75 }76 }7778 79 80 81 82 if (vault.isUnlocked.value && parsed.passwordId != null &&83 (parsed.isNewPassword || !hasLoginMatch)84 ) {85 runCatching {86 AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {87 response.addDataset(it)88 datasetCount++89 }90 }91 }9293 94 val pickerLabel = autofillPickerLabel(95 unlocked = vault.isUnlocked.value,96 datasetCount = datasetCount,97 )98 runCatching {99 response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))100 datasetCount++101 }102103 if (datasetCount == 0) {104 callback.onSuccess(null)105 } else {106 ignoreUnfilledIds(response, parsed)107 addSaveInfo(response, parsed)108 callback.onSuccess(response.build())109 }110 }111112 private fun pickerDataset(113 context: android.content.Context,114 parsed: ParsedStructure,115 label: String,116 inlineSpec: android.widget.inline.InlinePresentationSpec?,117 ): android.service.autofill.Dataset {118 val subtitle = parsed.origin.orEmpty()119 val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)120 val builder = android.service.autofill.Dataset.Builder(menu)121 122 123 (listOfNotNull(124 parsed.identityId, parsed.passwordId, parsed.otpId,125 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,126 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,127 ) + parsed.otpDigitBoxIds).forEach { id ->128 builder.setValue(id, null, menu)129 }130 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))131 return builder.build()132 }133134 135 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {136 val ids = listOfNotNull(137 parsed.identityId, parsed.passwordId, parsed.otpId,138 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,139 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,140 ) + parsed.otpDigitBoxIds141 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())142 }143144 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {145 val passwordId = parsed.passwordId ?: return146 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()147 val saveInfo = SaveInfo.Builder(148 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,149 arrayOf(passwordId),150 ).apply {151 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)152 }.build()153 builder.setSaveInfo(saveInfo)154 }155156 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =157 request.fillContexts.lastOrNull()?.structure158159 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {160 if (Build.VERSION.SDK_INT < 30) return emptyList()161 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()162 }163164 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {165 val structure = request.fillContexts.lastOrNull()?.structure166 if (structure == null || !vault.isUnlocked.value) {167 callback.onSuccess()168 return169 }170 val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))171 val draft = autofillSaveDraft(parsed)172 if (draft == null) {173 callback.onSuccess()174 return175 }176 serviceScope.launch {177 try {178 withContext(Dispatchers.IO) {179 when (val plan = planAutofillSave(vault.getEntries(), draft)) {180 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)181 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)182 }183 }184 callback.onSuccess()185 } catch (e: Exception) {186 callback.onFailure(e.message ?: "Peach could not save this login")187 }188 }189 }190191 override fun onDestroy() {192 serviceScope.cancel()193 super.onDestroy()194 }195196 private companion object {197 const val MAX_DATASETS = 6198 }199}200201internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {202 !unlocked -> "Unlock Peach"203 datasetCount == 0 -> "No saved logins for this site"204 else -> "Search Peach…"205}