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 listOf(87 Action(88 "Choose another Peach passkey",89 PasskeyCredentialSelectionActivity.choosePendingIntent(this, request),90 "Search eligible passkeys",91 ),92 )93 } else emptyList()94 callback.onResult(BeginGetCredentialResponse(credentialEntries = entries, actions = actions))95 }9697 private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {98 if (!vault.settings.value.passkeyProviderEnabled) return emptyList()99 if (!license.hasFeature(ProFeature.Passkeys)) {100 proNoticeStore.record(ProFeature.Passkeys)101 return emptyList()102 }103 val passkeyOptions = request.beginGetCredentialOptions104 .filterIsInstance<BeginGetPublicKeyCredentialOption>()105 if (passkeyOptions.isEmpty()) return emptyList()106107 val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)108 109 110 val callerOrigin = request.callingAppInfo?.let {111 PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))112 }113 return passkeyOptions.flatMap { option ->114 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->115 PasskeyCredentialEntryFactory.create(116 context = this,117 username = candidate.username,118 pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(119 this,120 candidate.entryId,121 ),122 option = option,123 displayName = candidate.displayName,124 lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),125 icon = icon,126 )127 }128 }129 }130131 132133134135136137138 private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {139 val option = request.beginGetCredentialOptions140 .filterIsInstance<BeginGetPasswordOption>()141 .firstOrNull() ?: return emptyList()142 val info = request.callingAppInfo ?: return emptyList()143144 val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)145 val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()146 val domains = if (!webOrigin.isNullOrBlank()) {147 setOfNotNull(extractDomain(webOrigin))148 } else {149 appLinkDomains(this, info.packageName).toSet()150 }151152 val candidates = PasswordCredentialPlanner.candidates(153 vault.getEntries(),154 domains,155 packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },156 allowedUserIds = option.allowedUserIds,157 )158 if (candidates.isEmpty()) return emptyList()159160 val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)161 return candidates.map { candidate ->162 PasswordCredentialEntry.Builder(163 this,164 candidate.username,165 PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),166 option,167 )168 .setDisplayName(candidate.title)169 .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))170 .setIcon(icon)171 .setAutoSelectAllowed(false)172 .build()173 }174 }175176 override fun onBeginCreateCredentialRequest(177 request: BeginCreateCredentialRequest,178 cancellationSignal: CancellationSignal,179 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,180 ) {181 if (!vault.isUnlocked.value) {182 callback.onResult(BeginCreateCredentialResponse())183 return184 }185 when (request) {186 is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)187 is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)188 else -> callback.onResult(BeginCreateCredentialResponse())189 }190 }191192 193194195196197198 private fun passwordCreateEntry(199 request: BeginCreatePasswordCredentialRequest,200 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,201 ) {202 203 204 205 val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")206 val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")207 if (id.isNullOrBlank() || password.isNullOrBlank()) {208 callback.onResult(BeginCreateCredentialResponse())209 return210 }211 val entries = vault.getEntries()212 val entry = CreateEntry(213 accountName = "Peach",214 pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),215 description = "Save password to Peach",216 lastUsedTime = Instant.now(),217 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),218 passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },219 publicKeyCredentialCount = entries.count { it.passkey != null },220 totalCredentialCount = entries.size,221 isAutoSelectAllowed = false,222 )223 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))224 }225226 private fun passkeyCreateEntry(227 publicKeyRequest: BeginCreatePublicKeyCredentialRequest,228 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,229 ) {230 if (!vault.settings.value.passkeyProviderEnabled) {231 callback.onResult(BeginCreateCredentialResponse())232 return233 }234 if (!license.hasFeature(ProFeature.Passkeys)) {235 proNoticeStore.record(ProFeature.Passkeys)236 callback.onResult(BeginCreateCredentialResponse())237 return238 }239 val count = vault.getEntries().count { it.passkey != null }240 val entry = CreateEntry(241 accountName = "Peach",242 pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),243 description = "Save passkey to Peach",244 lastUsedTime = Instant.now(),245 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),246 passwordCredentialCount = null,247 publicKeyCredentialCount = count,248 totalCredentialCount = vault.getEntries().size,249 isAutoSelectAllowed = false,250 )251 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))252 }253254 override fun onClearCredentialStateRequest(255 request: ProviderClearCredentialStateRequest,256 cancellationSignal: CancellationSignal,257 callback: OutcomeReceiver<Void?, ClearCredentialException>,258 ) {259 callback.onResult(null)260 }261}