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 response = FillResponse.Builder()64 var datasetCount = 065 var hasLoginMatch = false66 67 68 69 70 71 val systemChipCount = inlineSystemChipCount(inlineSpecs.size)72 val entryChipBudget = (inlineSpecs.size - systemChipCount).coerceAtLeast(0)73 74 75 76 77 78 79 80 val pickerSpec = pickerSpecFor(inlineSpecs)81 82 83 84 85 86 87 88 89 90 val otpEligible = parsed.otpId != null &&91 vault.getEntries().any { !it.totp.isNullOrBlank() }92 val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null93 var entryChips = 09495 if (unlocked) {96 97 98 99 100 val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all101 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 120 121 122 123 124 125 126 127 128 runCatching {129 matches.firstOrNull { !it.totp.isNullOrBlank() }?.let { entry ->130 TOTPGenerator.generateCode(entry.totp!!)?.let { code ->131 PeachClipboard.copy(context, label = "Peach 2FA", value = code, hint = true)132 if (com.peachpasswords.BuildConfig.DEBUG) {133 android.util.Log.d(134 "PeachAutofill",135 "2FA clipboard copy fired (entry=" + entry.title + ", code=" + code + ")",136 )137 }138 }139 }140 }.onFailure { t ->141 android.util.Log.w("PeachAutofill", "2FA clipboard copy failed", t)142 }143 }144145 if (parsed.hasCardField && unlocked) {146 vault.getEntries().filter { it.isCard() }.forEach { entry ->147 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null148 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {149 response.addDataset(it)150 datasetCount++151 }152 }153 }154155 156 157 158 159 if (unlocked && parsed.passwordId != null &&160 (parsed.isNewPassword || !hasLoginMatch)161 ) {162 runCatching {163 AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {164 response.addDataset(it)165 datasetCount++166 }167 }168 }169170 171 172 173 174 runCatching {175 response.addDataset(176 AutofillResponseBuilder.searchPeachDataset(177 context = context,178 parsed = parsed,179 inlineSpec = pickerSpec,180 unlocked = unlocked,181 hasMatches = hasLoginMatch,182 )183 )184 datasetCount++185 }186 if (datasetCount == 0) {187 callback.onSuccess(null)188 } else {189 ignoreUnfilledIds(response, parsed)190 addSaveInfo(response, parsed)191 callback.onSuccess(response.build())192 }193 }194195 196 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {197 val ids = listOfNotNull(198 parsed.identityId, parsed.passwordId, parsed.otpId,199 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,200 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,201 ) + parsed.otpDigitBoxIds202 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())203 }204205 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {206 val passwordId = parsed.passwordId ?: return207 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()208 val saveInfo = SaveInfo.Builder(209 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,210 arrayOf(passwordId),211 ).apply {212 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)213 }.build()214 builder.setSaveInfo(saveInfo)215 }216217 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =218 request.fillContexts.lastOrNull()?.structure219220 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {221 if (Build.VERSION.SDK_INT < 30) return emptyList()222 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()223 }224225 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {226 val structure = request.fillContexts.lastOrNull()?.structure227 if (structure == null || !vault.isUnlocked.value) {228 callback.onSuccess()229 return230 }231 val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))232 val draft = autofillSaveDraft(parsed)233 if (draft == null) {234 callback.onSuccess()235 return236 }237 serviceScope.launch {238 try {239 withContext(Dispatchers.IO) {240 when (val plan = planAutofillSave(vault.getEntries(), draft)) {241 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)242 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)243 }244 }245 callback.onSuccess()246 } catch (e: Exception) {247 callback.onFailure(e.message ?: "Peach could not save this login")248 }249 }250 }251252 override fun onDestroy() {253 serviceScope.cancel()254 super.onDestroy()255 }256257}258259260261262263264265internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0266267268269270271272internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).coerceAtLeast(0)273274internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {275 !unlocked -> "Unlock Peach"276 datasetCount == 0 -> "No saved logins for this site"277 else -> "Search Peach…"278}279280281282283284285286287288289290internal fun <T> pickerSpecFor(specs: List<T>): T? =291 if (specs.size >= 2) specs[specs.size - 2] else null