package com.peachpasswords.passkeys import android.os.Build import android.os.CancellationSignal import android.graphics.drawable.Icon import androidx.credentials.provider.BeginCreateCredentialRequest import androidx.credentials.provider.BeginCreateCredentialResponse import androidx.credentials.provider.BeginCreatePasswordCredentialRequest import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetCredentialResponse import androidx.credentials.provider.BeginGetPasswordOption import androidx.credentials.provider.BeginGetPublicKeyCredentialOption import androidx.credentials.provider.CredentialEntry import androidx.credentials.provider.CreateEntry import androidx.credentials.provider.AuthenticationAction import androidx.credentials.provider.Action import androidx.credentials.provider.CredentialProviderService import androidx.credentials.provider.ProviderClearCredentialStateRequest import androidx.credentials.provider.PublicKeyCredentialEntry import androidx.credentials.provider.PasswordCredentialEntry import android.os.OutcomeReceiver import androidx.credentials.exceptions.ClearCredentialException import androidx.credentials.exceptions.CreateCredentialException import androidx.credentials.exceptions.GetCredentialException import androidx.annotation.RequiresApi import com.peachpasswords.R import com.peachpasswords.autofill.appLinkDomains import com.peachpasswords.credentials.PasswordCredentialPlanner import com.peachpasswords.credentials.PasswordCredentialSelectionActivity import com.peachpasswords.license.LicenseRepository import com.peachpasswords.license.ProFeature import com.peachpasswords.license.ProNoticeStore import com.peachpasswords.shared.extractDomain import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.AndroidEntryPoint import java.time.Instant import javax.inject.Inject /** * Android 14+ credential provider surface for Peach. * * The Begin* callbacks MUST be answered or the Credential Manager times out and * reports a provider error; the previous scaffold left them uninvoked. This * version always completes the callbacks with a valid (possibly empty) * response, so Peach registers cleanly as a provider without erroring. * * Begin-get surfaces two independent families: * - passkeys (Pro-gated, opt-in toggle, fail-closed caller binding) — * selection completes the full ES256/Ed25519 assertion or registration * ceremony via PasskeyCredentialSelectionActivity; * - passwords (always offered, content-matched like autofill) — selection * releases the credential via PasswordCredentialSelectionActivity. */ @AndroidEntryPoint @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) class PeachCredentialProviderService : CredentialProviderService() { @Inject lateinit var vault: VaultRepository @Inject lateinit var license: LicenseRepository @Inject lateinit var proNoticeStore: ProNoticeStore override fun onBeginGetCredentialRequest( request: BeginGetCredentialRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver, ) { if (!vault.isUnlocked.value) { // Password rows are always eligible, so a locked vault always gets // the unlock action regardless of the passkey toggle. callback.onResult( BeginGetCredentialResponse( authenticationActions = listOf( AuthenticationAction( title = "Unlock Peach", pendingIntent = PasskeyUnlockIntent.pendingIntent(this, request), ), ), ), ) return } val passkeys = passkeyEntries(request) val entries = passkeys + passwordEntries(request) val actions = if (passkeys.isNotEmpty()) { listOf( Action( "Choose another Peach passkey", PasskeyCredentialSelectionActivity.choosePendingIntent(this, request), "Search eligible passkeys", ), ) } else emptyList() callback.onResult(BeginGetCredentialResponse(credentialEntries = entries, actions = actions)) } private fun passkeyEntries(request: BeginGetCredentialRequest): List { if (!vault.settings.value.passkeyProviderEnabled) return emptyList() if (!license.hasFeature()) { proNoticeStore.record(ProFeature.Passkeys) return emptyList() } val passkeyOptions = request.beginGetCredentialOptions .filterIsInstance() if (passkeyOptions.isEmpty()) return emptyList() val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark) // F1: resolve the framework-verified caller origin once (privileged // browser allowlist, else apk-key-hash). Unknown caller -> no rows. val callerOrigin = request.callingAppInfo?.let { PasskeyCallerOrigin.resolve(it, PasskeyCallerOrigin.privilegedAllowlist(this)) } return passkeyOptions.flatMap { option -> PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson, callerOrigin).map { candidate -> PasskeyCredentialEntryFactory.create( context = this, username = candidate.username, pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent( this, candidate.entryId, ), option = option, displayName = candidate.displayName, lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L), icon = icon, requireCredentialManagerVerification = true, ) } } } /** * Password rows for the system credential picker. Matching mirrors * autofill: privileged-browser callers match on the web origin's * registrable domain; native callers match on their verified app-link * domains. No trust gate — the caller package is framework-authenticated * (queue #3 stance). */ private fun passwordEntries(request: BeginGetCredentialRequest): List { val option = request.beginGetCredentialOptions .filterIsInstance() .firstOrNull() ?: return emptyList() val info = request.callingAppInfo ?: return emptyList() val allowlist = PasskeyCallerOrigin.privilegedAllowlist(this) val webOrigin = runCatching { info.getOrigin(allowlist) }.getOrNull() val domains = if (!webOrigin.isNullOrBlank()) { setOfNotNull(extractDomain(webOrigin)) } else { appLinkDomains(this, info.packageName).toSet() } val candidates = PasswordCredentialPlanner.candidates( vault.getEntries(), domains, packageName = info.packageName?.takeIf { webOrigin.isNullOrBlank() }, allowedUserIds = option.allowedUserIds, ) if (candidates.isEmpty()) return emptyList() val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark) return candidates.map { candidate -> PasswordCredentialEntry.Builder( this, candidate.username, PasswordCredentialSelectionActivity.pendingIntent(this, candidate.entryId), option, ) .setDisplayName(candidate.title) .setLastUsedTime(Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L)) .setIcon(icon) .setAutoSelectAllowed(false) .build() } } override fun onBeginCreateCredentialRequest( request: BeginCreateCredentialRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver, ) { if (!vault.isUnlocked.value) { callback.onResult(BeginCreateCredentialResponse()) return } when (request) { is BeginCreatePasswordCredentialRequest -> passwordCreateEntry(request, callback) is BeginCreatePublicKeyCredentialRequest -> passkeyCreateEntry(request, callback) else -> callback.onResult(BeginCreateCredentialResponse()) } } /** * "Save password to Peach" in the system save sheet. Not Pro-gated and not * toggle-gated — saving is a core capability, exactly like the autofill * save flow. The actual add/update decision happens at tap time in * PasswordCredentialSelectionActivity via planAutofillSave. */ private fun passwordCreateEntry( request: BeginCreatePasswordCredentialRequest, callback: OutcomeReceiver, ) { // androidx.credentials.CreatePasswordRequest's BUNDLE_KEY_* constants // are @RestrictTo-internal in Kotlin but are the stable wire keys the // library serializes create requests with — read them directly. val id = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_ID") val password = request.candidateQueryData.getString("androidx.credentials.BUNDLE_KEY_PASSWORD") if (id.isNullOrBlank() || password.isNullOrBlank()) { callback.onResult(BeginCreateCredentialResponse()) return } val entries = vault.getEntries() val entry = CreateEntry( accountName = "Peach", pendingIntent = PasswordCredentialSelectionActivity.createPendingIntent(this, id, password), description = "Save password to Peach", lastUsedTime = Instant.now(), icon = Icon.createWithResource(this, R.drawable.ic_peach_mark), passwordCredentialCount = entries.count { !it.password.isNullOrBlank() }, publicKeyCredentialCount = entries.count { it.passkey != null }, totalCredentialCount = entries.size, isAutoSelectAllowed = false, ) callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry))) } private fun passkeyCreateEntry( publicKeyRequest: BeginCreatePublicKeyCredentialRequest, callback: OutcomeReceiver, ) { if (!vault.settings.value.passkeyProviderEnabled) { callback.onResult(BeginCreateCredentialResponse()) return } if (!license.hasFeature()) { proNoticeStore.record(ProFeature.Passkeys) callback.onResult(BeginCreateCredentialResponse()) return } val count = vault.getEntries().count { it.passkey != null } val entry = CreateEntry( accountName = "Peach", pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson), description = "Save passkey to Peach", lastUsedTime = Instant.now(), icon = Icon.createWithResource(this, R.drawable.ic_peach_mark), passwordCredentialCount = null, publicKeyCredentialCount = count, totalCredentialCount = vault.getEntries().size, isAutoSelectAllowed = false, ) callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry))) } override fun onClearCredentialStateRequest( request: ProviderClearCredentialStateRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver, ) { callback.onResult(null) } }