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.appLabel28import 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),77 ),78 ),79 ),80 )81 return82 }83 val entries = passkeyEntries(request) + passwordEntries(request)84 callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))85 }8687 private fun passkeyEntries(request: BeginGetCredentialRequest): List<CredentialEntry> {88 if (!vault.settings.value.passkeyProviderEnabled) return emptyList()89 if (!license.hasFeature(ProFeature.Passkeys)) {90 proNoticeStore.record(ProFeature.Passkeys)91 return emptyList()92 }93 val passkeyOptions = request.beginGetCredentialOptions94 .filterIsInstance<BeginGetPublicKeyCredentialOption>()95 if (passkeyOptions.isEmpty()) return emptyList()9697 val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)98 99 100 val callerOrigin = request.callingAppInfo?.let {101 PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this))102 }103 return passkeyOptions.flatMap { option ->104 PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate ->105 PublicKeyCredentialEntry(106 context = this,107 username = candidate.username,108 pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(109 this,110 candidate.entryId,111 option.requestJson,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: Set<String>139 val appTitle: String?140 if (!webOrigin.isNullOrBlank()) {141 domains = setOfNotNull(extractDomain(webOrigin))142 appTitle = null143 } else {144 domains = appLinkDomains(this, info.packageName).toSet()145 appTitle = appLabel(this, info.packageName)146 }147148 val candidates = PasswordCredentialPlanner.candidates(149 vault.getEntries(),150 domains,151 appTitle,152 option.allowedUserIds,153 )154 if (candidates.isEmpty()) return emptyList()155156 val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)157 return candidates.map { candidate ->158 PasswordCredentialEntry.Builder(159 this,160 candidate.username,161 PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId),162 option,163 )164 .setDisplayName(candidate.title)165 .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L))166 .setIcon(icon)167 .setAutoSelectAllowed(false)168 .build()169 }170 }171172 override fun onBeginCreateCredentialRequest(173 request: BeginCreateCredentialRequest,174 cancellationSignal: CancellationSignal,175 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,176 ) {177 if (!vault.isUnlocked.value) {178 callback.onResult(BeginCreateCredentialResponse())179 return180 }181 when (request) {182 is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback)183 is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback)184 else -> callback.onResult(BeginCreateCredentialResponse())185 }186 }187188 189190191192193194 private fun passwordCreateEntry(195 request: BeginCreatePasswordCredentialRequest,196 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,197 ) {198 199 200 201 val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID")202 val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD")203 if (id.isNullOrBlank() || password.isNullOrBlank()) {204 callback.onResult(BeginCreateCredentialResponse())205 return206 }207 val entries = vault.getEntries()208 val entry = CreateEntry(209 accountName = "Peach",210 pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password),211 description = "Save password to Peach",212 lastUsedTime = Instant.now(),213 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),214 passwordCredentialCount = entries.count { !it.password.isNullOrBlank() },215 publicKeyCredentialCount = entries.count { it.passkey != null },216 totalCredentialCount = entries.size,217 isAutoSelectAllowed = false,218 )219 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))220 }221222 private fun passkeyCreateEntry(223 publicKeyRequest: BeginCreatePublicKeyCredentialRequest,224 callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,225 ) {226 if (!vault.settings.value.passkeyProviderEnabled) {227 callback.onResult(BeginCreateCredentialResponse())228 return229 }230 if (!license.hasFeature(ProFeature.Passkeys)) {231 proNoticeStore.record(ProFeature.Passkeys)232 callback.onResult(BeginCreateCredentialResponse())233 return234 }235 val count = vault.getEntries().count { it.passkey != null }236 val entry = CreateEntry(237 accountName = "Peach",238 pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),239 description = "Save passkey to Peach",240 lastUsedTime = Instant.now(),241 icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),242 passwordCredentialCount = null,243 publicKeyCredentialCount = count,244 totalCredentialCount = vault.getEntries().size,245 isAutoSelectAllowed = false,246 )247 callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))248 }249250 override fun onClearCredentialStateRequest(251 request: ProviderClearCredentialStateRequest,252 cancellationSignal: CancellationSignal,253 callback: OutcomeReceiver<Void?, ClearCredentialException>,254 ) {255 callback.onResult(null)256 }257}