PeachCredentialProviderService.kt

6.2 KB · raw

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.BeginCreatePublicKeyCredentialRequest9import androidx.credentials.provider.BeginGetCredentialRequest10import androidx.credentials.provider.BeginGetCredentialResponse11import androidx.credentials.provider.BeginGetPublicKeyCredentialOption12import androidx.credentials.provider.CreateEntry13import androidx.credentials.provider.AuthenticationAction14import androidx.credentials.provider.CredentialProviderService15import androidx.credentials.provider.ProviderClearCredentialStateRequest16import androidx.credentials.provider.PublicKeyCredentialEntry17import android.os.OutcomeReceiver18import androidx.credentials.exceptions.ClearCredentialException19import androidx.credentials.exceptions.CreateCredentialException20import androidx.credentials.exceptions.GetCredentialException21import androidx.annotation.RequiresApi22import com.peachpasswords.R23import com.peachpasswords.license.LicenseRepository24import com.peachpasswords.license.ProFeature25import com.peachpasswords.license.ProNoticeStore26import com.peachpasswords.vault.VaultRepository27import dagger.hilt.android.AndroidEntryPoint28import java.time.Instant29import javax.inject.Inject3031/**32 * Android 14+ credential provider surface for Peach passkeys.33 *34 * The Begin* callbacks MUST be answered or the Credential Manager times out and35 * reports a provider error; the previous scaffold left them uninvoked. This36 * version always completes the callbacks with a valid (currently empty)37 * response, so Peach registers cleanly as a provider without erroring.38 *39 * Begin-get now surfaces unlocked, matching passkeys as system credential rows.40 * The selected pending intent still returns an explicit unsupported result until41 * the ES256/WebAuthn assertion ceremony lands.42 */43@AndroidEntryPoint44@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)45class PeachCredentialProviderService : CredentialProviderService() {4647    @Inject lateinit var vault: VaultRepository48    @Inject lateinit var license: LicenseRepository49    @Inject lateinit var proNoticeStore: ProNoticeStore5051    override fun onBeginGetCredentialRequest(52        request: BeginGetCredentialRequest,53        cancellationSignal: CancellationSignal,54        callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,55    ) {56        if (!vault.settings.value.passkeyProviderEnabled) {57            callback.onResult(BeginGetCredentialResponse())58            return59        }60        if (!license.hasFeature(ProFeature.Passkeys)) {61            proNoticeStore.record(ProFeature.Passkeys)62            callback.onResult(BeginGetCredentialResponse())63            return64        }65        if (!vault.isUnlocked.value) {66            callback.onResult(67                BeginGetCredentialResponse(68                    authenticationActions = listOf(69                        AuthenticationAction(70                            title = "Unlock Peach",71                            pendingIntent = PasskeyUnlockIntent.pendingIntent(this),72                        ),73                    ),74                ),75            )76            return77        }78        val passkeyOptions = request.beginGetCredentialOptions79            .filterIsInstance<BeginGetPublicKeyCredentialOption>()80        if (passkeyOptions.isEmpty()) {81            callback.onResult(BeginGetCredentialResponse())82            return83        }8485        val icon = Icon.createWithResource(this, R.drawable.ic_peach_mark)86        val entries = passkeyOptions.flatMap { option ->87            PasskeyCredentialPlanner.candidates(vault.getEntries(), option.requestJson).map { candidate ->88                PublicKeyCredentialEntry(89                    context = this,90                    username = candidate.username,91                    pendingIntent = PasskeyCredentialSelectionActivity.pendingIntent(92                        this,93                        candidate.entryId,94                        option.requestJson,95                    ),96                    beginGetPublicKeyCredentialOption = option,97                    displayName = candidate.displayName,98                    lastUsedTime = Instant.ofEpochMilli(candidate.lastUsedAt ?: 0L),99                    icon = icon,100                    isAutoSelectAllowed = false,101                )102            }103        }104        callback.onResult(BeginGetCredentialResponse(credentialEntries = entries))105    }106107    override fun onBeginCreateCredentialRequest(108        request: BeginCreateCredentialRequest,109        cancellationSignal: CancellationSignal,110        callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,111    ) {112        val publicKeyRequest = request as? BeginCreatePublicKeyCredentialRequest113        if (publicKeyRequest == null || !vault.settings.value.passkeyProviderEnabled || !vault.isUnlocked.value) {114            callback.onResult(BeginCreateCredentialResponse())115            return116        }117        if (!license.hasFeature(ProFeature.Passkeys)) {118            proNoticeStore.record(ProFeature.Passkeys)119            callback.onResult(BeginCreateCredentialResponse())120            return121        }122        val count = vault.getEntries().count { it.passkey != null }123        val entry = CreateEntry(124            accountName = "Peach",125            pendingIntent = PasskeyCredentialSelectionActivity.createPendingIntent(this, publicKeyRequest.requestJson),126            description = "Save passkey to Peach",127            lastUsedTime = Instant.now(),128            icon = Icon.createWithResource(this, R.drawable.ic_peach_mark),129            passwordCredentialCount = null,130            publicKeyCredentialCount = count,131            totalCredentialCount = vault.getEntries().size,132            isAutoSelectAllowed = false,133        )134        callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))135    }136137    override fun onClearCredentialStateRequest(138        request: ProviderClearCredentialStateRequest,139        cancellationSignal: CancellationSignal,140        callback: OutcomeReceiver<Void?, ClearCredentialException>,141    ) {142        callback.onResult(null)143    }144}