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 = authenticateAutofillOrigin(context, AssistStructureParser.parse(structure))47 if (!parsed.hasUsableField) { callback.onSuccess(null); return }4849 val inlineSpecs = inlineSpecs(request)50 val response = FillResponse.Builder()51 var datasetCount = 052 var hasLoginMatch = false5354 if (vault.isUnlocked.value) {55 val matches = AutofillResponseBuilder56 .matchEntries(vault.getEntries(), parsed)57 .all.take(MAX_DATASETS)58 matches.forEachIndexed { index, entry ->59 val spec = inlineSpecs.getOrNull(index)60 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec)?.let {61 response.addDataset(it)62 datasetCount++63 hasLoginMatch = true64 }65 }66 }6768 if (parsed.hasCardField && vault.isUnlocked.value) {69 vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEachIndexed { index, entry ->70 val spec = inlineSpecs.getOrNull(datasetCount + index)71 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec)?.let {72 response.addDataset(it)73 datasetCount++74 }75 }76 }7778 79 80 81 82 if (vault.isUnlocked.value && parsed.passwordId != null &&83 (parsed.isNewPassword || !hasLoginMatch)84 ) {85 runCatching {86 AutofillResponseBuilder.generateDatasetFor(context, parsed)?.let {87 response.addDataset(it)88 datasetCount++89 }90 }91 }9293 94 val pickerLabel = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN && vault.isUnlocked.value) {95 "Verify site in Peach…"96 } else {97 autofillPickerLabel(98 unlocked = vault.isUnlocked.value,99 datasetCount = datasetCount,100 )101 }102 runCatching {103 response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))104 datasetCount++105 }106107 if (datasetCount == 0) {108 callback.onSuccess(null)109 } else {110 ignoreUnfilledIds(response, parsed)111 if (parsed.originTrust == AutofillOriginTrust.TRUSTED_WEB) {112 addSaveInfo(response, parsed)113 }114 callback.onSuccess(response.build())115 }116 }117118 private fun pickerDataset(119 context: android.content.Context,120 parsed: ParsedStructure,121 label: String,122 inlineSpec: android.widget.inline.InlinePresentationSpec?,123 ): android.service.autofill.Dataset {124 val subtitle = if (parsed.originTrust == AutofillOriginTrust.UNKNOWN) {125 "Origin not verified"126 } else {127 parsed.origin.orEmpty()128 }129 val menu = AutofillResponseBuilder.simpleRow(context, label, subtitle)130 val builder = android.service.autofill.Dataset.Builder(menu)131 132 133 listOfNotNull(134 parsed.identityId, parsed.passwordId, parsed.otpId,135 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,136 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,137 ).forEach { id ->138 builder.setValue(id, null, menu)139 }140 builder.setAuthentication(AutofillIntents.pickerIntentSender(context, parsed))141 return builder.build()142 }143144 145 private fun ignoreUnfilledIds(builder: FillResponse.Builder, parsed: ParsedStructure) {146 val ids = listOfNotNull(147 parsed.identityId, parsed.passwordId, parsed.otpId,148 parsed.cardNumberId, parsed.cardExpMonthId, parsed.cardExpYearId,149 parsed.cardExpDateId, parsed.cardCvcId, parsed.cardNameId,150 )151 if (ids.isNotEmpty()) builder.setIgnoredIds(*ids.toTypedArray())152 }153154 private fun addSaveInfo(builder: FillResponse.Builder, parsed: ParsedStructure) {155 val passwordId = parsed.passwordId ?: return156 val optionalIds = listOfNotNull(parsed.identityId, parsed.otpId).toTypedArray()157 val saveInfo = SaveInfo.Builder(158 SaveInfo.SAVE_DATA_TYPE_USERNAME or SaveInfo.SAVE_DATA_TYPE_PASSWORD,159 arrayOf(passwordId),160 ).apply {161 if (optionalIds.isNotEmpty()) setOptionalIds(optionalIds)162 }.build()163 builder.setSaveInfo(saveInfo)164 }165166 private fun latestStructure(request: FillRequest): android.app.assist.AssistStructure? =167 request.fillContexts.lastOrNull()?.structure168169 private fun inlineSpecs(request: FillRequest): List<android.widget.inline.InlinePresentationSpec> {170 if (Build.VERSION.SDK_INT < 30) return emptyList()171 return request.inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()172 }173174 override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {175 val structure = request.fillContexts.lastOrNull()?.structure176 if (structure == null || !vault.isUnlocked.value) {177 callback.onSuccess()178 return179 }180 val parsed = authenticateAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))181 if (parsed.originTrust != AutofillOriginTrust.TRUSTED_WEB) {182 callback.onSuccess()183 return184 }185 val draft = autofillSaveDraft(parsed)186 if (draft == null) {187 callback.onSuccess()188 return189 }190 serviceScope.launch {191 try {192 withContext(Dispatchers.IO) {193 when (val plan = planAutofillSave(vault.getEntries(), draft)) {194 is AutofillSavePlan.Add -> vault.addEntry(plan.entry)195 is AutofillSavePlan.Update -> vault.updateEntry(plan.entry)196 }197 }198 callback.onSuccess()199 } catch (e: Exception) {200 callback.onFailure(e.message ?: "Peach could not save this login")201 }202 }203 }204205 override fun onDestroy() {206 serviceScope.cancel()207 super.onDestroy()208 }209210 private companion object {211 const val MAX_DATASETS = 6212 }213}214215internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {216 !unlocked -> "Unlock Peach"217 datasetCount == 0 -> "No saved logins for this site"218 else -> "Search Peach…"219}