1package com.peachpasswords.passkeys23import android.os.Build4import android.os.CancellationSignal5import android.graphics.drawable.Icon6import androidx.credentials.provider.BeginCreateCredentialRequest7import androidx.credentials.provider.BeginCreateCredentialResponse8import androidx.credentials.provider.BeginCreatePasswordCredentialRequest9import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest10import androidx.credentials.provider.BeginGetCredentialRequest11import androidx.credentials.provider.BeginGetCredentialResponse12import androidx.credentials.provider.BeginGetPasswordOption13import androidx.credentials.provider.BeginGetPublicKeyCredentialOption14import androidx.credentials.provider.CredentialEntry15import androidx.credentials.provider.CreateEntry16import androidx.credentials.provider.AuthenticationAction17import androidx.credentials.provider.Action18import androidx.credentials.provider.CredentialProviderService19import androidx.credentials.provider.ProviderClearCredentialStateRequest20import androidx.credentials.provider.PublicKeyCredentialEntry21import androidx.credentials.provider.PasswordCredentialEntry22import android.os.OutcomeReceiver23import androidx.credentials.exceptions.ClearCredentialException24import androidx.credentials.exceptions.CreateCredentialException25import androidx.credentials.exceptions.GetCredentialException26import androidx.annotation.RequiresApi27import com.peachpasswords.R28import com.peachpasswords.autofill.appLinkDomains29import com.peachpasswords.credentials.PasswordCredentialPlanner30import com.peachpasswords.credentials.PasswordCredentialSelectionActivity31import com.peachpasswords.license.LicenseRepository32import com.peachpasswords.license.ProFeature33import com.peachpasswords.license.ProNoticeStore34import com.peachpasswords.shared.extractDomain35import com.peachpasswords.vault.VaultRepository36import dagger.hilt.android.AndroidEntryPoint37import java.time.Instant38import javax.inject.Inject3940414243444546474849505152535455@AndroidEntryPoint56@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)57class PeachCredentialProviderService : CredentialProviderService() {5859 @Inject lateinit var vault: VaultRepository60 @Inject lateinit var license: LicenseRepository61 @Inject lateinit var proNoticeStore: ProNoticeStore6263 override fun onBeginGetCredentialRequest(64 request: BeginGetCredentialRequest,65 cancellationSignal: CancellationSignal,66 callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,67 ) {68 if (!vault.isUnlocked.value) {69 70 71 callback.onResult(72 BeginGetCredentialResponse(73 authenticationActions = listOf(74 AuthenticationAction(75 title = "Unlock Peach",76 pendingIntent = PasskeyUnlockIntent.pendingIntent(this, request),77 ),78 ),79 ),80 )81 return82 }83 val passkeys = passkeyEntries(request)84 val entries = passkeys + passwordEntries(request)85 val actions = if (passkeys.isNotEmpty()) {86 val origin = request.callingAppInfo?.let {87 PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))88 }89 listOf(90 Action(91 "Choose another Peach passkey",92 PasskeyCredentialSelectionActivity.choosePendingIntent(this, request, origin),93 "Search eligible passkeys",94 ),95 )96 } else emptyList()97 callback.onResult(BeginGetCredentialResponse(credentialEntries = entries, actions = actions))98 }99100 private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {101 if (!vault.settings.value.passkeyProviderEnabled) return emptyList()102 if (!license.hasFeature(ProFeature.Passkeys)) {103 proNoticeStore.record(ProFeature.Passkeys)104 return emptyList()105 }106 val passkeyOptions = request.beginGetCredentialOptions107 .filterIsInstance<BeginGetPublicKeyCredentialOption>()108 if (passkeyOptions.isEmpty()) return emptyList()109110 val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)111 112 113 val callerOrigin = request.callingAppInfo?.let {114 PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))115 }116 return passkeyOptions.flatMap { option ->117 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->118 PublicKeyCredentialEntry(119 context = this,120 username = candidate.username,121 pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(122 this,123 candidate.entryId,124 option.requestJson,125 callerOrigin,126 option.clientDataHash,127 ),128 beginGetPublicKeyCredentialOption = option,129 displayName = candidate.displayName,130 lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),131 icon = icon,132 isAutoSelectAllowed = false,133 )134 }135 }136 }137138 139140141142143144145 private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {146 val option = request.beginGetCredentialOptions147 .filterIsInstance<BeginGetPasswordOption>()148 .firstOrNull() ?: return emptyList()149 val info = request.callingAppInfo ?: return emptyList()150151 val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)152 val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()153 val domains = if (!webOrigin.isNullOrBlank()) {154 setOfNotNull(extractDomain(webOrigin))155 } else {156 appLinkDomains(this, info.packageName).toSet()157 }158159 val candidates = PasswordCredentialPlanner.candidates(160 vault.getEntries(),161 domains,162 packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },163 allowedUserIds = option.allowedUserIds,164 )165 if (candidates.isEmpty()) return emptyList()166167 val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)168 return candidates.map { candidate ->169 PasswordCredentialEntry.Builder(170 this,171 candidate.username,172 PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),173 option,174 )175 .setDisplayName(candidate.title)176 .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))177 .setIcon(icon)178 .setAutoSelectAllowed(false)179 .build()180 }181 }182183 override fun onBeginCreateCredentialRequest(184 request: BeginCreateCredentialRequest,185 cancellationSignal: CancellationSignal,186 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,187 ) {188 if (!vault.isUnlocked.value) {189 callback.onResult(BeginCreateCredentialResponse())190 return191 }192 when (request) {193 is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)194 is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)195 else -> callback.onResult(BeginCreateCredentialResponse())196 }197 }198199 200201202203204205 private fun passwordCreateEntry(206 request: BeginCreatePasswordCredentialRequest,207 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,208 ) {209 210 211 212 val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")213 val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")214 if (id.isNullOrBlank() || password.isNullOrBlank()) {215 callback.onResult(BeginCreateCredentialResponse())216 return217 }218 val entries = vault.getEntries()219 val entry = CreateEntry(220 accountName = "Peach",221 pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),222 description = "Save password to Peach",223 lastUsedTime = Instant.now(),224 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),225 passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },226 publicKeyCredentialCount = entries.count { it.passkey != null },227 totalCredentialCount = entries.size,228 isAutoSelectAllowed = false,229 )230 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))231 }232233 private fun passkeyCreateEntry(234 publicKeyRequest: BeginCreatePublicKeyCredentialRequest,235 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,236 ) {237 if (!vault.settings.value.passkeyProviderEnabled) {238 callback.onResult(BeginCreateCredentialResponse())239 return240 }241 if (!license.hasFeature(ProFeature.Passkeys)) {242 proNoticeStore.record(ProFeature.Passkeys)243 callback.onResult(BeginCreateCredentialResponse())244 return245 }246 val count = vault.getEntries().count { it.passkey != null }247 val entry = CreateEntry(248 accountName = "Peach",249 pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),250 description = "Save passkey to Peach",251 lastUsedTime = Instant.now(),252 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),253 passwordCredentialCount = null,254 publicKeyCredentialCount = count,255 totalCredentialCount = vault.getEntries().size,256 isAutoSelectAllowed = false,257 )258 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))259 }260261 override fun onClearCredentialStateRequest(262 request: ProviderClearCredentialStateRequest,263 cancellationSignal: CancellationSignal,264 callback: OutcomeReceiver<Void?, ClearCredentialException>,265 ) {266 callback.onResult(null)267 }268}