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 requestId = request.id,158 )159 )160 datasetCount++161 }162 if (datasetCount == 0) {163 callback.onSuccess(null)164 } else {165 ignoreUnfilledIds(response, parsed)166 addSaveInfo(response, parsed)167 callback.onSuccess(response.build())168 }169 }170171 172 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {173 val ids = listOfNotNull(174 parsed.identityId, parsed.passwordId, parsed.otpId,175 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,176 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,177 ) + parsed.otpDigitBoxIds178 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())179 }180181 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {182 val passwordId = parsed.passwordId ?: return183 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()184 val saveInfo = SaveInfo.Builder(185 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,186 arrayOf(passwordId),187 ).apply {188 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)189 }.build()190 builder.setSaveInfo(saveInfo)191 }192193 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =194 request.fillContexts.lastOrNull()?.structure195196 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {197 if (Build.VERSION.SDK_INT < 30) return emptyList()198 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()199 }200201 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {202 val structure = request.fillContexts.lastOrNull()?.structure203 if (structure == null || !vault.isUnlocked.value) {204 callback.onSuccess()205 return206 }207 val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))208 if (!parsed.credentialReleaseAllowed) {209 callback.onSuccess()210 return211 }212 val draft = autofillSaveDraft(parsed, parsed.nativeSignerDigests.takeIf { parsed.webDomain == null })213 if (draft == null) {214 callback.onSuccess()215 return216 }217 serviceScope.launch {218 try {219 withContext(Dispatchers.IO) {220 when (val plan = planAutofillSave(vault.getEntries(), draft)) {221 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)222 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)223 }224 }225 callback.onSuccess()226 } catch (e: Exception) {227 callback.onFailure(e.message ?: "Peach could not save this login")228 }229 }230 }231232 override fun onDestroy() {233 serviceScope.cancel()234 super.onDestroy()235 }236237}238239240241242243244245internal fun inlineSystemChipCount(totalSpecs: Int, otpEligible: Boolean): Int =246 if (totalSpecs == 0) 0 else if (otpEligible) 2 else 1247248249internal fun inlineChipBudget(totalSpecs: Int, otpEligible: Boolean): Int =250 (totalSpecs - inlineSystemChipCount(totalSpecs, otpEligible)).coerceAtLeast(0)251252internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {253 !unlocked -> "Unlock Peach"254 datasetCount == 0 -> "No saved logins for this site"255 else -> "Search Peach…"256}257258259260261262263264265266267268269270internal fun <T> pickerSpecFor(specs: List<T>, otpEligible: Boolean): T? = when {271 specs.isEmpty() -> null272 otpEligible -> if (specs.size >= 2) specs[specs.size - 2] else null273 else -> specs.last()274}