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 (!parsed.hasCredentialField) { callback.onSuccess(null); return }5051 val inlineSpecs = inlineSpecs(request)52 val privatePreview = vault.settings.value.hideAutofillPreviews53 val unlocked = vault.isUnlocked.value54 val response = FillResponse.Builder()55 var datasetCount = 056 var hasLoginMatch = false57 58 59 60 61 62 val systemChipCount = inlineSystemChipCount(inlineSpecs.size)63 val entryChipBudget = (inlineSpecs.size - systemChipCount).coerceAtLeast(0)64 65 66 67 68 69 70 71 val pickerSpec = pickerSpecFor(inlineSpecs)72 73 74 75 76 77 78 79 80 81 val otpEligible = parsed.otpId != null &&82 vault.getEntries().any { !it.totp.isNullOrBlank() }83 val otpSpec = if (otpEligible) inlineSpecs.lastOrNull() else null84 var entryChips = 08586 if (unlocked) {87 88 89 90 91 val matches = AutofillResponseBuilder.matchEntries(vault.getEntries(), parsed).all92 matches.forEach { entry ->93 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null94 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {95 response.addDataset(it)96 datasetCount++97 hasLoginMatch = true98 }99 100 101 102 103 if (!entry.totp.isNullOrBlank() && otpSpec != null) {104 AutofillResponseBuilder.otpDatasetFor(context, parsed, entry, otpSpec, privatePreview)?.let {105 response.addDataset(it)106 datasetCount++107 }108 }109 }110 111 112 113 114 115 116 matches.firstOrNull { !it.totp.isNullOrBlank() }?.let { entry ->117 TOTPGenerator.generateCode(entry.totp!!)?.let { code ->118 PeachClipboard.copy(context, label = "Peach 2FA", value = code, hint = true)119 }120 }121 }122123 if (parsed.hasCardField && unlocked) {124 vault.getEntries().filter { it.isCard() }.forEach { entry ->125 val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null126 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {127 response.addDataset(it)128 datasetCount++129 }130 }131 }132133 134 135 136 137 if (unlocked && parsed.passwordId != null &&138 (parsed.isNewPassword || !hasLoginMatch)139 ) {140 runCatching {141 AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {142 response.addDataset(it)143 datasetCount++144 }145 }146 }147148 149 150 151 152 runCatching {153 response.addDataset(154 AutofillResponseBuilder.searchPeachDataset(155 context = context,156 parsed = parsed,157 inlineSpec = pickerSpec,158 unlocked = unlocked,159 hasMatches = hasLoginMatch,160 )161 )162 datasetCount++163 }164 if (datasetCount == 0) {165 callback.onSuccess(null)166 } else {167 ignoreUnfilledIds(response, parsed)168 addSaveInfo(response, parsed)169 callback.onSuccess(response.build())170 }171 }172173 174 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {175 val ids = listOfNotNull(176 parsed.identityId, parsed.passwordId, parsed.otpId,177 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,178 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,179 ) + parsed.otpDigitBoxIds180 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())181 }182183 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {184 val passwordId = parsed.passwordId ?: return185 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()186 val saveInfo = SaveInfo.Builder(187 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,188 arrayOf(passwordId),189 ).apply {190 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)191 }.build()192 builder.setSaveInfo(saveInfo)193 }194195 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =196 request.fillContexts.lastOrNull()?.structure197198 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {199 if (Build.VERSION.SDK_INT < 30) return emptyList()200 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()201 }202203 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {204 val structure = request.fillContexts.lastOrNull()?.structure205 if (structure == null || !vault.isUnlocked.value) {206 callback.onSuccess()207 return208 }209 val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))210 val draft = autofillSaveDraft(parsed)211 if (draft == null) {212 callback.onSuccess()213 return214 }215 serviceScope.launch {216 try {217 withContext(Dispatchers.IO) {218 when (val plan = planAutofillSave(vault.getEntries(), draft)) {219 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)220 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)221 }222 }223 callback.onSuccess()224 } catch (e: Exception) {225 callback.onFailure(e.message ?: "Peach could not save this login")226 }227 }228 }229230 override fun onDestroy() {231 serviceScope.cancel()232 super.onDestroy()233 }234235}236237238239240241242243internal fun inlineSystemChipCount(totalSpecs: Int): Int = if (totalSpecs >= 2) 2 else if (totalSpecs == 1) 1 else 0244245246247248249250internal fun inlineChipBudget(totalSpecs: Int): Int = (totalSpecs - inlineSystemChipCount(totalSpecs)).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}257258259260261262263264265266267268internal fun <T> pickerSpecFor(specs: List<T>): T? =269 if (specs.size >= 2) specs[specs.size - 2] else null