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 (com.peachpasswords.BuildConfig.DEBUG) {48 android.util.Log.d(49 "PeachAutofill",50 "onFillRequest: hasCredentialField=${parsed.hasCredentialField} " +51 "otpId=${parsed.otpId != null} origin=${parsed.origin} " +52 "unlocked=${vault.isUnlocked.value} entryCount=${vault.getEntries().size} " +53 "specCount=${inlineSpecs(request).size}",54 )55 }56 if (!parsed.hasCredentialField || !parsed.credentialReleaseAllowed) {57 if (com.peachpasswords.BuildConfig.DEBUG && !parsed.credentialReleaseAllowed) {58 android.util.Log.d("PeachAutofill", "refused unverified web origin")59 }60 callback.onSuccess(null)61 return62 }6364 val inlineSpecs = inlineSpecs(request)65 val privatePreview = vault.settings.value.hideAutofillPreviews66 val unlocked = vault.isUnlocked.value67 val matches = if (unlocked) {68 AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all69 } else {70 emptyList()71 }72 val response = FillResponse.Builder()73 var datasetCount = 074 var hasLoginMatch = false75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 val otpEligible = parsed.otpId != null && matches.any { !it.totp.isNullOrBlank() }91 val entryChipBudget = inlineChipBudget(inlineSpecs.size, otpEligible)92 val pickerSpec = pickerSpecFor(inlineSpecs, otpEligible)93 val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null94 var entryChips = 09596 if (unlocked) {97 98 99 100 101 matches.forEach { entry ->102 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null103 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {104 response.addDataset(it)105 datasetCount++106 hasLoginMatch = true107 }108 109 110 111 112 if (!entry.totp.isNullOrBlank() && otpSpec != null) {113 AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {114 response.addDataset(it)115 datasetCount++116 }117 }118 }119 }120121 if (parsed.hasCardField && unlocked) {122 vault.getEntries().filter { it.isCard() }.forEach { entry ->123 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null124 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {125 response.addDataset(it)126 datasetCount++127 }128 }129 }130131 132 133 134 135 if (unlocked && parsed.passwordId != null &&136 (parsed.isNewPassword || !hasLoginMatch)137 ) {138 runCatching {139 AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {140 response.addDataset(it)141 datasetCount++142 }143 }144 }145146 147 148 149 runCatching {150 response.addDataset(151 AutofillResponseBuilder.searchPeachDataset(152 context = context,153 parsed = parsed,154 inlineSpec = pickerSpec,155 unlocked = unlocked,156 hasMatches = hasLoginMatch,157 )158 )159 datasetCount++160 }161 if (datasetCount == 0) {162 callback.onSuccess(null)163 } else {164 ignoreUnfilledIds(response, parsed)165 addSaveInfo(response, parsed)166 callback.onSuccess(response.build())167 }168 }169170 171 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {172 val ids = listOfNotNull(173 parsed.identityId, parsed.passwordId, parsed.otpId,174 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,175 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,176 ) + parsed.otpDigitBoxIds177 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())178 }179180 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {181 val passwordId = parsed.passwordId ?: return182 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()183 val saveInfo = SaveInfo.Builder(184 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,185 arrayOf(passwordId),186 ).apply {187 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)188 }.build()189 builder.setSaveInfo(saveInfo)190 }191192 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =193 request.fillContexts.lastOrNull()?.structure194195 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {196 if (Build.VERSION.SDK_INT < 30) return emptyList()197 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()198 }199200 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {201 val structure = request.fillContexts.lastOrNull()?.structure202 if (structure == null || !vault.isUnlocked.value) {203 callback.onSuccess()204 return205 }206 val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))207 if (!parsed.credentialReleaseAllowed) {208 callback.onSuccess()209 return210 }211 val draft = autofillSaveDraft(parsed)212 if (draft == null) {213 callback.onSuccess()214 return215 }216 serviceScope.launch {217 try {218 withContext(Dispatchers.IO) {219 when (val plan = planAutofillSave(vault.getEntries(), draft)) {220 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)221 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)222 }223 }224 callback.onSuccess()225 } catch (e: Exception) {226 callback.onFailure(e.message ?: "Peach could not save this login")227 }228 }229 }230231 override fun onDestroy() {232 serviceScope.cancel()233 super.onDestroy()234 }235236}237238239240241242243244internal fun inlineSystemChipCount(totalSpecs: Int, otpEligible: Boolean): Int =245 if (totalSpecs == 0) 0 else if (otpEligible) 2 else 1246247248internal fun inlineChipBudget(totalSpecs: Int, otpEligible: Boolean): Int =249 (totalSpecs - inlineSystemChipCount(totalSpecs, otpEligible)).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}256257258259260261262263264265266267268269internal fun <T> pickerSpecFor(specs: List<T>, otpEligible: Boolean): T? = when {270 specs.isEmpty() -> null271 otpEligible -> if (specs.size >= 2) specs[specs.size - 2] else null272 else -> specs.last()273}