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.BeginCreatePublicKeyCredentialRequest import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetCredentialResponse import androidx.credentials.provider.BeginGetPublicKeyCredentialOption import androidx.credentials.provider.CreateEntry import androidx.credentials.provider.AuthenticationAction import androidx.credentials.provider.CredentialProviderService import androidx.credentials.provider.ProviderClearCredentialStateRequest import androidx.credentials.provider.PublicKeyCredentialEntry 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.license.LicenseRepository import com.peachpasswords.license.ProFeature import com.peachpasswords.license.ProNoticeStore 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 passkeys. * * 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 (currently empty) * response, so Peach registers cleanly as a provider without erroring. * * Begin-get surfaces unlocked, matching passkeys as system credential rows. * Selection completes the full ES256/Ed25519 assertion or registration ceremony * via PasskeyCredentialSelectionActivity. */ @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.settings.value.passkeyProviderEnabled) { callback.onResult(BeginGetCredentialResponse()) return } if (!license.hasFeature(ProFeature.Passkeys)) { proNoticeStore.record(ProFeature.Passkeys) callback.onResult(BeginGetCredentialResponse()) return } if (!vault.isUnlocked.value) { callback.onResult( BeginGetCredentialResponse( authenticationActions = listOf( AuthenticationAction( title = "Unlock Peach", pendingIntent = PasskeyUnlockIntent.pendingIntent(this), ), ), ), ) return } val passkeyOptions = request.beginGetCredentialOptions .filterIsInstance() if (passkeyOptions.isEmpty()) { callback.onResult(BeginGetCredentialResponse()) return } val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark) val entries = passkeyOptions.flatMap { option -> PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson).map { candidate -> PublicKeyCredentialEntry( context = this, username = candidate.username, pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent( this, candidate.entryId, option.requestJson, ), beginGetPublicKeyCredentialOption = option, displayName = candidate.displayName, lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L), icon = icon, isAutoSelectAllowed = false, ) } } callback.onResult(BeginGetCredentialResponse(credentialEntries = entries)) } override fun onBeginCreateCredentialRequest( request: BeginCreateCredentialRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver, ) { val publicKeyRequest = request as? BeginCreatePublicKeyCredentialRequest if (publicKeyRequest == null || !vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) { callback.onResult(BeginCreateCredentialResponse()) return } if (!license.hasFeature(ProFeature.Passkeys)) { 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) } }