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 unlocked = vault.isUnlocked.value52 val response = FillResponse.Builder()53 var datasetCount = 054 var hasLoginMatch = false55 56 57 58 59 60 val systemChipCount = inlineSystemChipCount(inlineSpecs.size)61 val entryChipBudget = (inlineSpecs.size - systemChipCount).coerceAtLeast(0)62 val pickerSpec = if (inlineSpecs.size >= 2) {63 inlineSpecs[inlineSpecs.size - 2]64 } else {65 inlineSpecs.lastOrNull()66 }67 val createSpec = inlineSpecs.lastOrNull()68 69 70 71 72 73 74 75 76 val otpEligible = parsed.otpId != null &&77 vault.getEntries().any { !it.totp.isNullOrBlank() }78 val otpSpec = if (otpEligible) createSpec else null79 val otpTakesCreateSlot = otpEligible && createSpec != null80 var entryChips = 08182 if (unlocked) {83 84 85 86 87 val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all88 matches.forEach { entry ->89 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null90 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {91 response.addDataset(it)92 datasetCount++93 hasLoginMatch = true94 }95 96 97 98 99 if (!entry.totp.isNullOrBlank() && otpSpec != null) {100 AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {101 response.addDataset(it)102 datasetCount++103 }104 }105 }106 }107108 if (parsed.hasCardField && unlocked) {109 vault.getEntries().filter { it.isCard() }.forEach { entry ->110 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null111 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {112 response.addDataset(it)113 datasetCount++114 }115 }116 }117118 119 120 121 122 if (unlocked && parsed.passwordId != null &&123 (parsed.isNewPassword || !hasLoginMatch)124 ) {125 runCatching {126 AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {127 response.addDataset(it)128 datasetCount++129 }130 }131 }132133 134 135 136 137 runCatching {138 response.addDataset(139 AutofillResponseBuilder.searchPeachDataset(140 context = context,141 parsed = parsed,142 inlineSpec = pickerSpec,143 unlocked = unlocked,144 hasMatches = hasLoginMatch,145 )146 )147 datasetCount++148 }149 150 151 152 AutofillResponseBuilder.createNewEntryDataset(153 context = context,154 parsed = parsed,155 inlineSpec = if (otpTakesCreateSlot) null else createSpec,156 unlocked = unlocked,157 origin = parsed.origin,158 )?.let {159 response.addDataset(it)160 datasetCount++161 }162163 if (datasetCount == 0) {164 callback.onSuccess(null)165 } else {166 ignoreUnfilledIds(response, parsed)167 addSaveInfo(response, parsed)168 callback.onSuccess(response.build())169 }170 }171172 173 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {174 val ids = listOfNotNull(175 parsed.identityId, parsed.passwordId, parsed.otpId,176 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,177 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,178 ) + parsed.otpDigitBoxIds179 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())180 }181182 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {183 val passwordId = parsed.passwordId ?: return184 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()185 val saveInfo = SaveInfo.Builder(186 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,187 arrayOf(passwordId),188 ).apply {189 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)190 }.build()191 builder.setSaveInfo(saveInfo)192 }193194 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =195 request.fillContexts.lastOrNull()?.structure196197 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {198 if (Build.VERSION.SDK_INT < 30) return emptyList()199 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()200 }201202 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {203 val structure = request.fillContexts.lastOrNull()?.structure204 if (structure == null || !vault.isUnlocked.value) {205 callback.onSuccess()206 return207 }208 val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))209 val draft = autofillSaveDraft(parsed)210 if (draft == null) {211 callback.onSuccess()212 return213 }214 serviceScope.launch {215 try {216 withContext(Dispatchers.IO) {217 when (val plan = planAutofillSave(vault.getEntries(), draft)) {218 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)219 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)220 }221 }222 callback.onSuccess()223 } catch (e: Exception) {224 callback.onFailure(e.message ?: "Peach could not save this login")225 }226 }227 }228229 override fun onDestroy() {230 serviceScope.cancel()231 super.onDestroy()232 }233234}235236237238239240241242internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0243244245246247248249internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).coerceAtLeast(0)250251internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {252 !unlocked -> "Unlock Peach"253 datasetCount == 0 -> "No saved logins for this site"254 else -> "Search Peach…"255}