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.shared.PeachClipboard14import com.peachpasswords.vault.TOTPGenerator15import com.peachpasswords.vault.isCard16import dagger.hilt.android.AndroidEntryPoint17import kotlinx.coroutines.CoroutineScope18import kotlinx.coroutines.Dispatchers19import kotlinx.coroutines.SupervisorJob20import kotlinx.coroutines.cancel21import kotlinx.coroutines.launch22import kotlinx.coroutines.withContext23import javax.inject.Inject242526272829303132333435@AndroidEntryPoint36class PeachAutofillService : AutofillService() {3738 @Inject lateinit var vault: VaultRepository39 private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)4041 override fun onFillRequest(42 request: FillRequest,43 cancellationSignal: CancellationSignal,44 callback: FillCallback,45 ) {46 val context = applicationContext47 val structure = latestStructure(request) ?: run { callback.onSuccess(null); return }48 val parsed = resolveAutofillOrigin(context, AssistStructureParser.parse(structure))49 if (com.peachpasswords.BuildConfig.DEBUG) {50 android.util.Log.d(51 "PeachAutofill",52 "onFillRequest: hasCredentialField=${parsed.hasCredentialField} " +53 "otpId=${parsed.otpId != null} origin=${parsed.origin} " +54 "unlocked=${vault.isUnlocked.value} entryCount=${vault.getEntries().size} " +55 "specCount=${inlineSpecs(request).size}",56 )57 }58 if (!parsed.hasCredentialField) { callback.onSuccess(null); return }5960 val inlineSpecs = inlineSpecs(request)61 val privatePreview = vault.settings.value.hideAutofillPreviews62 val unlocked = vault.isUnlocked.value63 val matches = if (unlocked) {64 AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all65 } else {66 emptyList()67 }68 val response = FillResponse.Builder()69 var datasetCount = 070 var hasLoginMatch = false71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 val otpEligible = parsed.otpId != null && matches.any { !it.totp.isNullOrBlank() }87 val entryChipBudget = inlineChipBudget(inlineSpecs.size, otpEligible)88 val pickerSpec = pickerSpecFor(inlineSpecs, otpEligible)89 val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null90 var entryChips = 09192 if (unlocked) {93 94 95 96 97 matches.forEach { entry ->98 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null99 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {100 response.addDataset(it)101 datasetCount++102 hasLoginMatch = true103 }104 105 106 107 108 if (!entry.totp.isNullOrBlank() && otpSpec != null) {109 AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {110 response.addDataset(it)111 datasetCount++112 }113 }114 }115 116 117 118 119 120 121 122 123 124 runCatching {125 matches.firstOrNull { !it.totp.isNullOrBlank() }?.let { entry ->126 TOTPGenerator.generateCode(entry.totp!!)?.let { code ->127 PeachClipboard.copy(context, label = "Peach 2FA", value = code, hint = true)128 if (com.peachpasswords.BuildConfig.DEBUG) {129 android.util.Log.d(130 "PeachAutofill",131 "2FA clipboard copy fired (entry=" + entry.title + ", code=" + code + ")",132 )133 }134 }135 }136 }.onFailure { t ->137 android.util.Log.w("PeachAutofill", "2FA clipboard copy failed", t)138 }139 }140141 if (parsed.hasCardField && unlocked) {142 vault.getEntries().filter { it.isCard() }.forEach { entry ->143 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null144 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {145 response.addDataset(it)146 datasetCount++147 }148 }149 }150151 152 153 154 155 if (unlocked && parsed.passwordId != null &&156 (parsed.isNewPassword || !hasLoginMatch)157 ) {158 runCatching {159 AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {160 response.addDataset(it)161 datasetCount++162 }163 }164 }165166 167 168 169 runCatching {170 response.addDataset(171 AutofillResponseBuilder.searchPeachDataset(172 context = context,173 parsed = parsed,174 inlineSpec = pickerSpec,175 unlocked = unlocked,176 hasMatches = hasLoginMatch,177 )178 )179 datasetCount++180 }181 if (datasetCount == 0) {182 callback.onSuccess(null)183 } else {184 ignoreUnfilledIds(response, parsed)185 addSaveInfo(response, parsed)186 callback.onSuccess(response.build())187 }188 }189190 191 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {192 val ids = listOfNotNull(193 parsed.identityId, parsed.passwordId, parsed.otpId,194 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,195 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,196 ) + parsed.otpDigitBoxIds197 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())198 }199200 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {201 val passwordId = parsed.passwordId ?: return202 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()203 val saveInfo = SaveInfo.Builder(204 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,205 arrayOf(passwordId),206 ).apply {207 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)208 }.build()209 builder.setSaveInfo(saveInfo)210 }211212 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =213 request.fillContexts.lastOrNull()?.structure214215 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {216 if (Build.VERSION.SDK_INT < 30) return emptyList()217 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()218 }219220 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {221 val structure = request.fillContexts.lastOrNull()?.structure222 if (structure == null || !vault.isUnlocked.value) {223 callback.onSuccess()224 return225 }226 val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))227 val draft = autofillSaveDraft(parsed)228 if (draft == null) {229 callback.onSuccess()230 return231 }232 serviceScope.launch {233 try {234 withContext(Dispatchers.IO) {235 when (val plan = planAutofillSave(vault.getEntries(), draft)) {236 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)237 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)238 }239 }240 callback.onSuccess()241 } catch (e: Exception) {242 callback.onFailure(e.message ?: "Peach could not save this login")243 }244 }245 }246247 override fun onDestroy() {248 serviceScope.cancel()249 super.onDestroy()250 }251252}253254255256257258259260internal fun inlineSystemChipCount(totalSpecs: Int, otpEligible: Boolean): Int =261 if (totalSpecs == 0) 0 else if (otpEligible) 2 else 1262263264internal fun inlineChipBudget(totalSpecs: Int, otpEligible: Boolean): Int =265 (totalSpecs - inlineSystemChipCount(totalSpecs, otpEligible)).coerceAtLeast(0)266267internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {268 !unlocked -> "Unlock Peach"269 datasetCount == 0 -> "No saved logins for this site"270 else -> "Search Peach…"271}272273274275276277278279280281282283284285internal fun <T> pickerSpecFor(specs: List<T>, otpEligible: Boolean): T? = when {286 specs.isEmpty() -> null287 otpEligible -> if (specs.size >= 2) specs[specs.size - 2] else null288 else -> specs.last()289}