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 callerOrigin,112 ),113 beginGetPublicKeyCredentialOption = option,114 displayName = candidate.displayName,115 lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),116 icon = icon,117 isAutoSelectAllowed = false,118 )119 }120 }121 }122123 124125126127128129130 private fun passwordEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {131 val option = request.beginGetCredentialOptions132 .filterIsInstance<BeginGetPasswordOption>()133 .firstOrNull() ?: return emptyList()134 val info = request.callingAppInfo ?: return emptyList()135136 val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this)137 val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull()138 val domains = if (!webOrigin.isNullOrBlank()) {139 setOfNotNull(extractDomain(webOrigin))140 } else {141 appLinkDomains(this, info.packageName).toSet()142 }143144 val candidates = PasswordCredentialPlanner.candidates(145 vault.getEntries(),146 domains,147 packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() },148 allowedUserIds = option.allowedUserIds,149 )150 if (candidates.isEmpty()) return emptyList()151152 val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)153 return candidates.map { candidate ->154 PasswordCredentialEntry.Builder(155 this,156 candidate.username,157 PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),158 option,159 )160 .setDisplayName(candidate.title)161 .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))162 .setIcon(icon)163 .setAutoSelectAllowed(false)164 .build()165 }166 }167168 override fun onBeginCreateCredentialRequest(169 request: BeginCreateCredentialRequest,170 cancellationSignal: CancellationSignal,171 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,172 ) {173 if (!vault.isUnlocked.value) {174 callback.onResult(BeginCreateCredentialResponse())175 return176 }177 when (request) {178 is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)179 is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)180 else -> callback.onResult(BeginCreateCredentialResponse())181 }182 }183184 185186187188189190 private fun passwordCreateEntry(191 request: BeginCreatePasswordCredentialRequest,192 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,193 ) {194 195 196 197 val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")198 val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")199 if (id.isNullOrBlank() || password.isNullOrBlank()) {200 callback.onResult(BeginCreateCredentialResponse())201 return202 }203 val entries = vault.getEntries()204 val entry = CreateEntry(205 accountName = "Peach",206 pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),207 description = "Save password to Peach",208 lastUsedTime = Instant.now(),209 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),210 passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },211 publicKeyCredentialCount = entries.count { it.passkey != null },212 totalCredentialCount = entries.size,213 isAutoSelectAllowed = false,214 )215 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))216 }217218 private fun passkeyCreateEntry(219 publicKeyRequest: BeginCreatePublicKeyCredentialRequest,220 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,221 ) {222 if (!vault.settings.value.passkeyProviderEnabled) {223 callback.onResult(BeginCreateCredentialResponse())224 return225 }226 if (!license.hasFeature(ProFeature.Passkeys)) {227 proNoticeStore.record(ProFeature.Passkeys)228 callback.onResult(BeginCreateCredentialResponse())229 return230 }231 val count = vault.getEntries().count { it.passkey != null }232 val entry = CreateEntry(233 accountName = "Peach",234 pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),235 description = "Save passkey to Peach",236 lastUsedTime = Instant.now(),237 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),238 passwordCredentialCount = null,239 publicKeyCredentialCount = count,240 totalCredentialCount = vault.getEntries().size,241 isAutoSelectAllowed = false,242 )243 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))244 }245246 override fun onClearCredentialStateRequest(247 request: ProviderClearCredentialStateRequest,248 cancellationSignal: CancellationSignal,249 callback: OutcomeReceiver<Void?, ClearCredentialException>,250 ) {251 callback.onResult(null)252 }253}