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.CredentialProviderService18import androidx.credentials.provider.ProviderClearCredentialStateRequest19import androidx.credentials.provider.PublicKeyCredentialEntry20import androidx.credentials.provider.PasswordCredentialEntry21import android.os.OutcomeReceiver22import androidx.credentials.exceptions.ClearCredentialException23import androidx.credentials.exceptions.CreateCredentialException24import androidx.credentials.exceptions.GetCredentialException25import androidx.annotation.RequiresApi26import com.peachpasswords.R27import com.peachpasswords.autofill.appLinkDomains28import com.peachpasswords.credentials.PasswordCredentialPlanner29import com.peachpasswords.credentials.PasswordCredentialSelectionActivity30import com.peachpasswords.license.LicenseRepository31import com.peachpasswords.license.ProFeature32import com.peachpasswords.license.ProNoticeStore33import com.peachpasswords.shared.extractDomain34import com.peachpasswords.vault.VaultRepository35import dagger.hilt.android.AndroidEntryPoint36import java.time.Instant37import javax.inject.Inject3839404142434445464748495051525354@AndroidEntryPoint55@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)56class PeachCredentialProviderService : CredentialProviderService() {5758 @Inject lateinit var vault: VaultRepository59 @Inject lateinit var license: LicenseRepository60 @Inject lateinit var proNoticeStore: ProNoticeStore6162 override fun onBeginGetCredentialRequest(63 request: BeginGetCredentialRequest,64 cancellationSignal: CancellationSignal,65 callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,66 ) {67 if (!vault.isUnlocked.value) {68 69 70 callback.onResult(71 BeginGetCredentialResponse(72 authenticationActions = listOf(73 AuthenticationAction(74 title = "Unlock Peach",75 pendingIntent = PasskeyUnlockIntent.pendingIntent(this, request),76 ),77 ),78 ),79 )80 return81 }82 val entries = passkeyEntries(request) + passwordEntries(request)83 callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))84 }8586 private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {87 if (!vault.settings.value.passkeyProviderEnabled) return emptyList()88 if (!license.hasFeature(ProFeature.Passkeys)) {89 proNoticeStore.record(ProFeature.Passkeys)90 return emptyList()91 }92 val passkeyOptions = request.beginGetCredentialOptions93 .filterIsInstance<BeginGetPublicKeyCredentialOption>()94 if (passkeyOptions.isEmpty()) return emptyList()9596 val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)97 98 99 val callerOrigin = request.callingAppInfo?.let {100 PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))101 }102 return passkeyOptions.flatMap { option ->103 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->104 PublicKeyCredentialEntry(105 context = this,106 username = candidate.username,107 pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(108 this,109 candidate.entryId,110 option.requestJson,111 ),112 beginGetPublicKeyCredentialOption = option,113 displayName = candidate.displayName,114 lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),115 icon = icon,116 isAutoSelectAllowed = false,117 )118 }119 }120 }121122 123124125126127128129 private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {130 val option = request.beginGetCredentialOptions131 .filterIsInstance<BeginGetPasswordOption>()132 .firstOrNull() ?: return emptyList()133 val info = request.callingAppInfo ?: return emptyList()134135 val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)136 val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()137 val domains = if (!webOrigin.isNullOrBlank()) {138 setOfNotNull(extractDomain(webOrigin))139 } else {140 appLinkDomains(this, info.packageName).toSet()141 }142143 val candidates = PasswordCredentialPlanner.candidates(144 vault.getEntries(),145 domains,146 packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },147 allowedUserIds = option.allowedUserIds,148 )149 if (candidates.isEmpty()) return emptyList()150151 val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)152 return candidates.map { candidate ->153 PasswordCredentialEntry.Builder(154 this,155 candidate.username,156 PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),157 option,158 )159 .setDisplayName(candidate.title)160 .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))161 .setIcon(icon)162 .setAutoSelectAllowed(false)163 .build()164 }165 }166167 override fun onBeginCreateCredentialRequest(168 request: BeginCreateCredentialRequest,169 cancellationSignal: CancellationSignal,170 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,171 ) {172 if (!vault.isUnlocked.value) {173 callback.onResult(BeginCreateCredentialResponse())174 return175 }176 when (request) {177 is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)178 is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)179 else -> callback.onResult(BeginCreateCredentialResponse())180 }181 }182183 184185186187188189 private fun passwordCreateEntry(190 request: BeginCreatePasswordCredentialRequest,191 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,192 ) {193 194 195 196 val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")197 val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")198 if (id.isNullOrBlank() || password.isNullOrBlank()) {199 callback.onResult(BeginCreateCredentialResponse())200 return201 }202 val entries = vault.getEntries()203 val entry = CreateEntry(204 accountName = "Peach",205 pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),206 description = "Save password to Peach",207 lastUsedTime = Instant.now(),208 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),209 passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },210 publicKeyCredentialCount = entries.count { it.passkey != null },211 totalCredentialCount = entries.size,212 isAutoSelectAllowed = false,213 )214 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))215 }216217 private fun passkeyCreateEntry(218 publicKeyRequest: BeginCreatePublicKeyCredentialRequest,219 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,220 ) {221 if (!vault.settings.value.passkeyProviderEnabled) {222 callback.onResult(BeginCreateCredentialResponse())223 return224 }225 if (!license.hasFeature(ProFeature.Passkeys)) {226 proNoticeStore.record(ProFeature.Passkeys)227 callback.onResult(BeginCreateCredentialResponse())228 return229 }230 val count = vault.getEntries().count { it.passkey != null }231 val entry = CreateEntry(232 accountName = "Peach",233 pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),234 description = "Save passkey to Peach",235 lastUsedTime = Instant.now(),236 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),237 passwordCredentialCount = null,238 publicKeyCredentialCount = count,239 totalCredentialCount = vault.getEntries().size,240 isAutoSelectAllowed = false,241 )242 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))243 }244245 override fun onClearCredentialStateRequest(246 request: ProviderClearCredentialStateRequest,247 cancellationSignal: CancellationSignal,248 callback: OutcomeReceiver<Void?, ClearCredentialException>,249 ) {250 callback.onResult(null)251 }252}