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) { callback.onSuccess(null); return }5758 val inlineSpecs = inlineSpecs(request)59 val privatePreview = vault.settings.value.hideAutofillPreviews60 val unlocked = vault.isUnlocked.value61 val matches = if (unlocked) {62 AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all63 } else {64 emptyList()65 }66 val response = FillResponse.Builder()67 var datasetCount = 068 var hasLoginMatch = false69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 val otpEligible = parsed.otpId != null && matches.any { !it.totp.isNullOrBlank() }85 val entryChipBudget = inlineChipBudget(inlineSpecs.size, otpEligible)86 val pickerSpec = pickerSpecFor(inlineSpecs, otpEligible)87 val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null88 var entryChips = 08990 if (unlocked) {91 92 93 94 95 matches.forEach { entry ->96 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null97 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {98 response.addDataset(it)99 datasetCount++100 hasLoginMatch = true101 }102 103 104 105 106 if (!entry.totp.isNullOrBlank() && otpSpec != null) {107 AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {108 response.addDataset(it)109 datasetCount++110 }111 }112 }113 }114115 if (parsed.hasCardField && unlocked) {116 vault.getEntries().filter { it.isCard() }.forEach { entry ->117 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null118 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {119 response.addDataset(it)120 datasetCount++121 }122 }123 }124125 126 127 128 129 if (unlocked && parsed.passwordId != null &&130 (parsed.isNewPassword || !hasLoginMatch)131 ) {132 runCatching {133 AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {134 response.addDataset(it)135 datasetCount++136 }137 }138 }139140 141 142 143 runCatching {144 response.addDataset(145 AutofillResponseBuilder.searchPeachDataset(146 context = context,147 parsed = parsed,148 inlineSpec = pickerSpec,149 unlocked = unlocked,150 hasMatches = hasLoginMatch,151 )152 )153 datasetCount++154 }155 if (datasetCount == 0) {156 callback.onSuccess(null)157 } else {158 ignoreUnfilledIds(response, parsed)159 addSaveInfo(response, parsed)160 callback.onSuccess(response.build())161 }162 }163164 165 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {166 val ids = listOfNotNull(167 parsed.identityId, parsed.passwordId, parsed.otpId,168 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,169 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,170 ) + parsed.otpDigitBoxIds171 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())172 }173174 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {175 val passwordId = parsed.passwordId ?: return176 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()177 val saveInfo = SaveInfo.Builder(178 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,179 arrayOf(passwordId),180 ).apply {181 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)182 }.build()183 builder.setSaveInfo(saveInfo)184 }185186 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =187 request.fillContexts.lastOrNull()?.structure188189 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {190 if (Build.VERSION.SDK_INT < 30) return emptyList()191 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()192 }193194 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {195 val structure = request.fillContexts.lastOrNull()?.structure196 if (structure == null || !vault.isUnlocked.value) {197 callback.onSuccess()198 return199 }200 val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))201 val draft = autofillSaveDraft(parsed)202 if (draft == null) {203 callback.onSuccess()204 return205 }206 serviceScope.launch {207 try {208 withContext(Dispatchers.IO) {209 when (val plan = planAutofillSave(vault.getEntries(), draft)) {210 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)211 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)212 }213 }214 callback.onSuccess()215 } catch (e: Exception) {216 callback.onFailure(e.message ?: "Peach could not save this login")217 }218 }219 }220221 override fun onDestroy() {222 serviceScope.cancel()223 super.onDestroy()224 }225226}227228229230231232233234internal fun inlineSystemChipCount(totalSpecs: Int, otpEligible: Boolean): Int =235 if (totalSpecs == 0) 0 else if (otpEligible) 2 else 1236237238internal fun inlineChipBudget(totalSpecs: Int, otpEligible: Boolean): Int =239 (totalSpecs - inlineSystemChipCount(totalSpecs, otpEligible)).coerceAtLeast(0)240241internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {242 !unlocked -> "Unlock Peach"243 datasetCount == 0 -> "No saved logins for this site"244 else -> "Search Peach…"245}246247248249250251252253254255256257258259internal fun <T> pickerSpecFor(specs: List<T>, otpEligible: Boolean): T? = when {260 specs.isEmpty() -> null261 otpEligible -> if (specs.size >= 2) specs[specs.size - 2] else null262 else -> specs.last()263}