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 privatePreview = vault.settings.value.hideAutofillPreviews51 val response = FillResponse.Builder()52 var datasetCount = 053 var hasLoginMatch = false54 55 56 57 val pickerSpec = inlineSpecs.lastOrNull()58 val entryChipBudget = inlineChipBudget(inlineSpecs.size)59 var entryChips = 06061 if (vault.isUnlocked.value) {62 val matches = AutofillResponseBuilder63 .matchEntries(vault.getEntries(), parsed)64 .all.take(MAX_DATASETS)65 matches.forEach { entry ->66 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null67 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {68 response.addDataset(it)69 datasetCount++70 hasLoginMatch = true71 }72 }73 }7475 if (parsed.hasCardField && vault.isUnlocked.value) {76 vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEach { entry ->77 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null78 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {79 response.addDataset(it)80 datasetCount++81 }82 }83 }8485 86 87 88 89 if (vault.isUnlocked.value && parsed.passwordId != null &&90 (parsed.isNewPassword || !hasLoginMatch)91 ) {92 runCatching {93 AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {94 response.addDataset(it)95 datasetCount++96 }97 }98 }99100 101 val pickerLabel = autofillPickerLabel(102 unlocked = vault.isUnlocked.value,103 datasetCount = datasetCount,104 )105 runCatching {106 response.addDataset(pickerDataset(context, parsed, pickerLabel, pickerSpec))107 datasetCount++108 }109110 if (datasetCount == 0) {111 callback.onSuccess(null)112 } else {113 ignoreUnfilledIds(response, parsed)114 addSaveInfo(response, parsed)115 callback.onSuccess(response.build())116 }117 }118119 private fun pickerDataset(120 context: android.content.Context,121 parsed: ParsedStructure,122 label: String,123 inlineSpec: android.widget.inline.InlinePresentationSpec?,124 ): android.service.autofill.Dataset {125 val subtitle = parsed.origin.orEmpty()126 val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)127 val builder = android.service.autofill.Dataset.Builder(menu)128 129 130 (listOfNotNull(131 parsed.identityId, parsed.passwordId, parsed.otpId,132 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,133 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,134 ) + parsed.otpDigitBoxIds).forEach { id ->135 builder.setValue(id, null, menu)136 }137 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))138 return builder.build()139 }140141 142 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {143 val ids = listOfNotNull(144 parsed.identityId, parsed.passwordId, parsed.otpId,145 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,146 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,147 ) + parsed.otpDigitBoxIds148 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())149 }150151 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {152 val passwordId = parsed.passwordId ?: return153 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()154 val saveInfo = SaveInfo.Builder(155 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,156 arrayOf(passwordId),157 ).apply {158 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)159 }.build()160 builder.setSaveInfo(saveInfo)161 }162163 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =164 request.fillContexts.lastOrNull()?.structure165166 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {167 if (Build.VERSION.SDK_INT < 30) return emptyList()168 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()169 }170171 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {172 val structure = request.fillContexts.lastOrNull()?.structure173 if (structure == null || !vault.isUnlocked.value) {174 callback.onSuccess()175 return176 }177 val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))178 val draft = autofillSaveDraft(parsed)179 if (draft == null) {180 callback.onSuccess()181 return182 }183 serviceScope.launch {184 try {185 withContext(Dispatchers.IO) {186 when (val plan = planAutofillSave(vault.getEntries(), draft)) {187 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)188 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)189 }190 }191 callback.onSuccess()192 } catch (e: Exception) {193 callback.onFailure(e.message ?: "Peach could not save this login")194 }195 }196 }197198 override fun onDestroy() {199 serviceScope.cancel()200 super.onDestroy()201 }202203 private companion object {204 const val MAX_DATASETS = 6205 }206}207208209internal fun inlineChipBudget(totalSpecs: Int): Int = if (totalSpecs > 0) totalSpecs - 1 else 0210211internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {212 !unlocked -> "Unlock Peach"213 datasetCount == 0 -> "No saved logins for this site"214 else -> "Search Peach…"215}