diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c20fd33b3b..518327d9ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -163,7 +163,7 @@ android:resource="@xml/shortcuts" /> - + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 8c8d1e1046..b7f89682da 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -40,6 +40,9 @@ class SettingsStore @Inject constructor( val data: Flow = store.data val isPaykitEnabled: Flow = localStore.data.map { it[PAYKIT_ENABLED_KEY] ?: false } + val isPubkyProfileSetupPending: Flow = localStore.data.map { + it[PUBKY_PROFILE_SETUP_PENDING_KEY] ?: false + } @Volatile var restoredMonitoredTypesFromBackup: Boolean = false @@ -68,6 +71,10 @@ class SettingsStore @Inject constructor( localStore.edit { it[PAYKIT_ENABLED_KEY] = value } } + suspend fun setPubkyProfileSetupPending(value: Boolean) { + localStore.edit { it[PUBKY_PROFILE_SETUP_PENDING_KEY] = value } + } + suspend fun addLastUsedTag(newTag: String) { store.updateData { currentSettings -> val combinedTags = (listOf(newTag) + currentSettings.lastUsedTags).distinct() @@ -100,6 +107,7 @@ class SettingsStore @Inject constructor( private const val TAG = "SettingsStore" private const val MAX_LAST_USED_TAGS = 10 private val PAYKIT_ENABLED_KEY = booleanPreferencesKey("paykit_enabled") + private val PUBKY_PROFILE_SETUP_PENDING_KEY = booleanPreferencesKey("pubky_profile_setup_pending") } } diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index 86ce14a809..14e1f70d2c 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import to.bitkit.utils.AppError import java.net.URI import java.net.URLDecoder +import java.net.URLEncoder import java.nio.charset.StandardCharsets enum class PubkyAuthClaim(val wireValue: String) { @@ -71,13 +72,23 @@ data class PubkyAuthRequest( val permissions: List, val serviceNames: List, val bitkitClaim: PubkyAuthClaim?, + val homeserverPublicKey: String? = null, + val signupToken: String? = null, + val authorizationUrl: String? = rawUrl, ) { + val isSignup: Boolean + get() = isSignupUrl(rawUrl) + companion object { + @Suppress("LongParameterList") fun parse( rawUrl: String, clientId: String, relay: String, capabilities: String, + homeserverPublicKey: String? = null, + signupToken: String? = null, + authorizationUrl: String? = rawUrl, ): Result = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim -> val permissions = parseCapabilities(capabilities) PubkyAuthRequest( @@ -88,9 +99,73 @@ data class PubkyAuthRequest( permissions = permissions, serviceNames = permissions.mapNotNull { extractServiceName(it.path) }.distinct(), bitkitClaim = bitkitClaim, + homeserverPublicKey = homeserverPublicKey, + signupToken = signupToken, + authorizationUrl = authorizationUrl, ) } + fun isProtocolUrl(rawUrl: String): Boolean = runCatching { + val uri = URI(rawUrl) + when (uri.scheme?.lowercase()) { + "pubkyauth" -> true + "pubkyring" -> uri.host.equals("signup", ignoreCase = true) + else -> false + } + }.getOrDefault(false) + + fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false) + + fun parseSignup(rawUrl: String): Result = runCatching { + val uri = URI(rawUrl) + require(uri.isSignupRequest()) { "Unsupported Pubky signup URL" } + val query = parseQuery(uri) + val homeserver = query.requiredSingle("hs") + val authorizesApp = uri.authorizesApp(query) + val relay = if (authorizesApp) query.requiredSingle("relay") else "" + val secret = if (authorizesApp) query.requiredSingle("secret") else "" + val capabilities = if (authorizesApp) query.requiredSingle("caps") else "" + val authorizationUrl = if (authorizesApp) { + ringAuthorizationUrl(relay, secret, capabilities) + } else { + null + } + + parse( + rawUrl = rawUrl, + clientId = "", + relay = relay, + capabilities = capabilities, + homeserverPublicKey = homeserver, + signupToken = query.optionalSingle("st"), + authorizationUrl = authorizationUrl, + ).getOrThrow().also { + require(it.bitkitClaim == null) { "Pubky signup does not support Bitkit companion claims" } + } + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) }, + ) + + private fun URI.isSignupRequest(): Boolean = when (scheme?.lowercase()) { + "pubkyring" -> host.equals("signup", ignoreCase = true) + "pubkyauth" -> isDirectSignupRequest() + else -> false + } + + private fun URI.isDirectSignupRequest(): Boolean = + scheme.equals("pubkyauth", ignoreCase = true) && (host ?: rawAuthority).let { + it.equals("direct_signup", ignoreCase = true) || it.equals("signup", ignoreCase = true) + } + + private fun URI.authorizesApp(query: Map>): Boolean = + scheme.equals("pubkyring", ignoreCase = true) || + ( + scheme.equals("pubkyauth", ignoreCase = true) && + (host ?: rawAuthority).equals("signup", ignoreCase = true) && + listOf("relay", "secret", "caps").any(query::containsKey) + ) + fun parseBitkitClaim(rawUrl: String, capabilities: String): Result = parseBitkitClaimValues(rawUrl).fold( onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) }, @@ -152,5 +227,31 @@ data class PubkyAuthRequest( } private fun decodeQueryComponent(value: String) = URLDecoder.decode(value, StandardCharsets.UTF_8.name()) + + private fun ringAuthorizationUrl(relay: String, secret: String, capabilities: String): String = + "pubkyauth:///?relay=${encodeQueryComponent(relay)}" + + "&secret=${encodeQueryComponent(secret)}&caps=${encodeQueryComponent(capabilities)}" + + private fun encodeQueryComponent(value: String) = + URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20") + + private fun parseQuery(uri: URI): Map> = uri.rawQuery.orEmpty() + .split("&") + .filter { it.isNotEmpty() } + .map { it.split("=", limit = 2) } + .groupBy( + keySelector = { decodeQueryComponent(it.first()) }, + valueTransform = { decodeQueryComponent(it.getOrElse(1) { "" }) }, + ) + + private fun Map>.requiredSingle(name: String): String = + optionalSingle(name)?.takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("Missing Pubky signup parameter: $name") + + private fun Map>.optionalSingle(name: String): String? { + val values = this[name].orEmpty() + require(values.size <= 1) { "Duplicate Pubky signup parameter: $name" } + return values.singleOrNull()?.takeIf { it.isNotBlank() } + } } } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 0de1109d58..c0947cfa0c 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -75,6 +75,7 @@ sealed class PubkyContactError(message: String) : AppError(message) { } private class PubkyAuthAttemptInactive : AppError("Auth attempt is no longer active") +data object PubkyAlreadySignedInError : AppError("Already signed in") private enum class AuthAttemptWaitResult { Approved, Inactive } @@ -157,12 +158,14 @@ class PubkyRepo @Inject constructor( data object RestorationFailed : InitResult } - init { - scope.launch { initialize() } - } + private val initializationJob = scope.launch { initialize() } // region Initialization + suspend fun awaitInitialization() = withContext(ioDispatcher) { + initializationJob.join() + } + suspend fun initialize() = withContext(ioDispatcher) { runSuspendCatching { ensureServiceInitialized() @@ -553,25 +556,41 @@ class PubkyRepo @Inject constructor( tags: List, avatarBytes: ByteArray?, ): Result { + if (settingsStore.isPubkyProfileSetupPending.first() && _publicKey.value != null) { + return runSuspendCatching { + withContext(ioDispatcher) { + val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" } + val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) + finishIdentityCreation(publicKey, name, bio, links, tags, imageUrl) + } + } + } + var shouldRevokeSessionOnFailure = false return try { val result = runSuspendCatching { withContext(ioDispatcher) { - val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow() - - val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null } - ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode } + settingsStore.setPubkyProfileSetupPending(false) + val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + val publicKeyZ32 = if (!storedSecretKeyHex.isNullOrEmpty()) { + pubkyService.signIn(storedSecretKeyHex) + pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() + } else { + val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() + val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null } + ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode } - shouldRevokeSessionOnFailure = true - runSuspendCatching { - pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second) - }.getOrElse { - Logger.warn("Retrying sign in after sign up failed", it, context = TAG) - pubkyService.signIn(secretKeyHex) + shouldRevokeSessionOnFailure = true + runSuspendCatching { + pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second) + }.getOrElse { + Logger.warn("Retrying sign in after sign up failed", it, context = TAG) + pubkyService.signIn(secretKeyHex) + } + publicKey } - val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } - writeProfile(name, bio, links, tags, imageUrl) + val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) shouldRevokeSessionOnFailure = false finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl) } @@ -584,6 +603,18 @@ class PubkyRepo @Inject constructor( } } + private suspend fun publishIdentityProfile( + name: String, + bio: String, + links: List, + tags: List, + avatarBytes: ByteArray?, + ): String? { + val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } + writeProfile(name, bio, links, tags, imageUrl) + return imageUrl + } + private suspend fun finishIdentityCreation( publicKey: String, name: String, @@ -605,6 +636,7 @@ class PubkyRepo @Inject constructor( _authState.update { PubkyAuthState.Authenticated } _profile.update { createdProfile } cacheMetadata(createdProfile) + settingsStore.setPubkyProfileSetupPending(false) notifyBackupStateChanged() Logger.info("Created identity for '${redacted(publicKey)}'", context = TAG) loadProfile() @@ -946,8 +978,23 @@ class PubkyRepo @Inject constructor( managedSecretKeyFor(publicKey) != null }.getOrDefault(false) + suspend fun hasIdentity(): Boolean = withContext(ioDispatcher) { + _publicKey.value != null || + !keychain.loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrEmpty() || + !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty() + } + suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { withContext(ioDispatcher) { + if (PubkyAuthRequest.isSignupUrl(authUrl)) { + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + pubkyService.validateSignupRequest( + authorizationUrl = request.authorizationUrl, + homeserverPublicKey = requireNotNull(request.homeserverPublicKey), + ) + return@withContext request + } + val details = pubkyService.parseAuthUrl(authUrl) PubkyAuthRequest.parse( rawUrl = authUrl, @@ -958,6 +1005,56 @@ class PubkyRepo @Inject constructor( } } + suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = initializeMutex.withLock { + runSuspendCatching { + withContext(ioDispatcher) { + require(request.isSignup) { "Not a Pubky signup request" } + if (hasIdentity()) throw PubkyAlreadySignedInError + + val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() + if (hasIdentity()) throw PubkyAlreadySignedInError + + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + val registeredSession = pubkyService.registerIdentity( + secretKeyHex = secretKeyHex, + homeserverZ32 = requireNotNull(request.homeserverPublicKey), + signupCode = request.signupToken, + ) + request.authorizationUrl?.let { pubkyService.approveRingAuth(it, secretKeyHex) } + var activated = false + try { + pubkyService.activateRegisteredIdentity(registeredSession) + activated = true + } finally { + if (!activated) { + withContext(NonCancellable) { + settingsStore.setPubkyProfileSetupPending(false) + } + } + } + + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + var pendingSaved = false + try { + settingsStore.setPubkyProfileSetupPending(true) + pendingSaved = true + } finally { + if (!pendingSaved) { + withContext(NonCancellable) { + runSuspendCatching { pubkyService.forgetSessionAccess() } + .onFailure { + Logger.warn("Failed to roll back Pubky signup session", it, context = TAG) + } + clearLocalState() + } + } + } + notifyBackupStateChanged() + } + } + } + suspend fun approveAuth( authUrl: String, expectedCapabilities: String, @@ -1320,6 +1417,7 @@ class PubkyRepo @Inject constructor( publicPaykitCleanupPending = publicPaykitCleanupPending, ) } + settingsStore.setPubkyProfileSetupPending(false) } private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 141fbd569b..16421d0b31 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -69,12 +69,14 @@ import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic import com.synonym.paykit.requiredSessionCapabilities import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Network import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env @@ -160,6 +162,20 @@ class PaykitSdkService @Inject constructor( private var activeAuthRequest: PubkyAuthRequest? = null private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() + private var sdkFactory: () -> PaykitSdk = { + PaykitSdk.withPaymentAdapterAndPubkyClientConfig( + stateStore = stateStore, + sessionProvider = sessionProvider, + paymentAdapter = paymentAdapter, + config = paykitSdkConfig(), + pubkyClient = pubkyClientConfig, + ) + } + + internal constructor(context: Context, keychain: Keychain, sdkFactory: () -> PaykitSdk) : this(context, keychain) { + this.sdkFactory = sdkFactory + isSetup.complete(Unit) + } @Suppress("TooGenericExceptionCaught") suspend fun initialize() { @@ -268,6 +284,40 @@ class PaykitSdkService @Inject constructor( return result } + suspend fun registerIdentity( + secretKeyHex: String, + homeserverPublicKey: String, + signupCode: String?, + ): PubkySessionBootstrapResult { + isSetup.await() + return bootstrap().signUp( + localSecretKey = localSecretKey(secretKeyHex), + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), + homeserverPublicKey = homeserverPublicKey, + signupCode = signupCode, + requiredCapabilities = requiredCapabilities(), + ) + } + + suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) { + isSetup.await() + val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + operationMutex.withLock { + var activated = false + try { + activateBootstrapResult( + result = result, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = true, + ) + activated = true + } finally { + if (!activated) clearRegisteredIdentityActivationLocked() + } + } + notifyBackupStateChanged() + } + suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } @@ -883,6 +933,15 @@ class PaykitSdkService @Inject constructor( publishReceiverMarkerIfLiveSessionAvailable(handle) } + private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) { + runSuspendCatching { sessionProvider.clearSessionAccess() } + .onFailure { Logger.warn("Failed to clear incomplete Pubky signup session", it, context = TAG) } + runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) } + .onFailure { Logger.warn("Failed to clear incomplete Pubky signup state", it, context = TAG) } + resetRuntime() + notifyBackupStateChanged() + } + private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) { runSuspendCatching { val capabilities = receiverCapabilities(handle) @@ -931,13 +990,7 @@ class PaykitSdkService @Inject constructor( private suspend fun handle(): PaykitSdk = handleMutex.withLock { sdk?.let { return@withLock it } - PaykitSdk.withPaymentAdapterAndPubkyClientConfig( - stateStore = stateStore, - sessionProvider = sessionProvider, - paymentAdapter = paymentAdapter, - config = paykitSdkConfig(), - pubkyClient = pubkyClientConfig, - ).also { sdk = it } + sdkFactory().also { sdk = it } } private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig( diff --git a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt index d0a4df8d1c..cd7a13870d 100644 --- a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt +++ b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt @@ -20,7 +20,7 @@ import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject import javax.inject.Singleton -/** Advertises Bitkit as a `pubkyauth` handler only while it can authorize requests locally. */ +/** Advertises Pubky signup and authorization handlers when their required identity state is available. */ @Singleton internal class PubkyAuthHandlerRegistrar @Inject constructor( @ApplicationContext private val context: Context, @@ -28,8 +28,17 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor( private val settingsStore: SettingsStore, @IoDispatcher ioDispatcher: CoroutineDispatcher, ) { + companion object { + private const val TAG = "PubkyAuthHandlerRegistrar" + private const val PUBKY_AUTH_ALIAS_CLASS = "to.bitkit.ui.MainActivityPubkyAuth" + + /** Handles signup links before a Pubky identity is available. */ + private const val PUBKY_SIGNUP_ALIAS_CLASS = "to.bitkit.ui.MainActivityPubkySignup" + } + private val scope: CoroutineScope = appScope(ioDispatcher, TAG) private val aliasComponent = ComponentName(context.packageName, PUBKY_AUTH_ALIAS_CLASS) + private val signupAliasComponent = ComponentName(context.packageName, PUBKY_SIGNUP_ALIAS_CLASS) private val started = AtomicBoolean() fun start() = start(scope) @@ -38,6 +47,7 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor( if (!started.compareAndSet(false, true)) return collectionScope.launch { + pubkyRepo.awaitInitialization() combine(settingsStore.isPaykitEnabled, pubkyRepo.publicKey) { localFlagEnabled, publicKey -> localFlagEnabled to publicKey } @@ -48,17 +58,19 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor( val hasSecretKey = isPaykitUiEnabled && hasIdentity && pubkyRepo.hasSecretKey() setAliasEnabled( + aliasComponent, canHandlePubkyAuth( isPaykitUiEnabled = isPaykitUiEnabled, hasIdentity = hasIdentity, hasSecretKey = hasSecretKey, ), ) + setAliasEnabled(signupAliasComponent, isPaykitUiEnabled && !hasIdentity) } } } - private fun setAliasEnabled(enabled: Boolean) { + private fun setAliasEnabled(component: ComponentName, enabled: Boolean) { val state = if (enabled) { PackageManager.COMPONENT_ENABLED_STATE_ENABLED @@ -68,24 +80,19 @@ internal class PubkyAuthHandlerRegistrar @Inject constructor( runCatching { context.packageManager.setComponentEnabledSetting( - aliasComponent, + component, state, PackageManager.DONT_KILL_APP, ) }.onSuccess { Logger.info( - "Updated pubkyauth handler to '${if (enabled) "enabled" else "disabled"}'", + "Updated Pubky handler '${component.className}' to '${if (enabled) "enabled" else "disabled"}'", context = TAG, ) }.onFailure { - Logger.error("Failed to update pubkyauth handler", it, context = TAG) + Logger.error("Failed to update Pubky handler '${component.className}'", it, context = TAG) } } - - companion object { - private const val TAG = "PubkyAuthHandlerRegistrar" - private const val PUBKY_AUTH_ALIAS_CLASS = "to.bitkit.ui.MainActivityPubkyAuth" - } } internal fun canHandlePubkyAuth( diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 79bebe0881..981925c211 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,20 +1,32 @@ package to.bitkit.services +import com.synonym.bitkitcore.approvePubkyAuth import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PaykitPublicKeys import com.synonym.paykit.PubkyAuthCompanionClaim +import com.synonym.paykit.PubkySessionBootstrapResult +import kotlinx.coroutines.withTimeoutOrNull import to.bitkit.async.ServiceQueue import to.bitkit.ext.runSuspendCatching import to.bitkit.utils.AppError import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import com.synonym.bitkitcore.parsePubkyAuthUrl as parseLegacyPubkyAuthUrl @Suppress("TooManyFunctions") @Singleton class PubkyService @Inject constructor( private val paykitSdkService: PaykitSdkService, ) { + companion object { + /** Maximum wait for a Ring relay approval response. */ + private val RING_AUTH_TIMEOUT = 30.seconds + } + suspend fun initialize() = ServiceQueue.CORE.background { paykitSdkService.initialize() } @@ -76,6 +88,19 @@ class PubkyService @Inject constructor( Unit } + suspend fun registerIdentity( + secretKeyHex: String, + homeserverZ32: String, + signupCode: String?, + ): PubkySessionBootstrapResult = + ServiceQueue.CORE.background { + paykitSdkService.registerIdentity(secretKeyHex, homeserverZ32, signupCode) + } + + suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) = ServiceQueue.CORE.background { + paykitSdkService.activateRegisteredIdentity(result) + } + suspend fun signIn(secretKeyHex: String): Unit = ServiceQueue.CORE.background { paykitSdkService.signIn(secretKeyHex) Unit @@ -106,6 +131,13 @@ class PubkyService @Inject constructor( PaykitSdkService.parseAuthUrl(url) } + suspend fun validateSignupRequest(authorizationUrl: String?, homeserverPublicKey: String): Unit = + ServiceQueue.CORE.background { + authorizationUrl?.let { parseLegacyPubkyAuthUrl(it) } + PaykitPublicKeys.normalize(homeserverPublicKey) + Unit + } + suspend fun approveAuth( authUrl: String, expectedCapabilities: String, @@ -115,6 +147,16 @@ class PubkyService @Inject constructor( paykitSdkService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex) } + suspend fun approveRingAuth( + authUrl: String, + secretKeyHex: String, + timeout: Duration = RING_AUTH_TIMEOUT, + ) = ServiceQueue.CORE.background { + withTimeoutOrNull(timeout) { + approvePubkyAuth(authUrl, secretKeyHex) + } ?: throw PubkyRingAuthTimeoutError() + } + suspend fun approveAuthWithCompanionClaim( authUrl: String, expectedCapabilities: String, @@ -188,3 +230,5 @@ class PubkyService @Inject constructor( // endregion } + +class PubkyRingAuthTimeoutError : AppError("Ring authorization timed out") diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index d903bebf26..552ab9a087 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -459,6 +459,7 @@ fun ContentView( val hasSeenWidgetsIntro by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle() val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() + val isPubkyProfileSetupPending by settingsViewModel.isPubkyProfileSetupPending.collectAsStateWithLifecycle() val hasSeenContactsIntro by settingsViewModel.hasSeenContactsIntro.collectAsStateWithLifecycle() val isProfileAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle() val hasPubkyContacts by settingsViewModel.hasPubkyContacts.collectAsStateWithLifecycle() @@ -648,6 +649,7 @@ fun ContentView( ) { Box(modifier = Modifier.fillMaxSize()) { var isHomeCalculatorInputActive by remember { mutableStateOf(false) } + val pubkyProfileSetupNavigation = remember { PubkyProfileSetupNavigation() } RootNavHost( navController = navController, @@ -668,6 +670,25 @@ fun ContentView( val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route + LaunchedEffect( + isPaykitEnabled, + isPubkyProfileSetupPending, + isProfileAuthenticated, + currentSheet, + currentRoute, + ) { + val canNavigate = currentSheet == null && + currentRoute != Routes.CreateProfile::class.qualifiedName + if (pubkyProfileSetupNavigation.shouldNavigate( + isEnabled = isPaykitEnabled, + isPending = isPubkyProfileSetupPending, + isAuthenticated = isProfileAuthenticated, + canNavigate = canNavigate, + ) + ) { + navController.navigateTo(Routes.CreateProfile) + } + } val currentHardwareWalletId = navBackStackEntry ?.takeIf { it.destination.hasRoute() } ?.toRoute() @@ -727,6 +748,26 @@ fun ContentView( } } +internal class PubkyProfileSetupNavigation { + private var didResume = false + + fun shouldNavigate( + isEnabled: Boolean, + isPending: Boolean, + isAuthenticated: Boolean, + canNavigate: Boolean, + ): Boolean { + if (!isPending) { + didResume = false + return false + } + if (didResume) return false + if (!isEnabled || !isAuthenticated || !canNavigate) return false + didResume = true + return true + } +} + @Composable private fun RootNavHost( navController: NavHostController, @@ -1577,7 +1618,7 @@ private fun NavGraphBuilder.shop( page = it.toRoute().page, title = it.toRoute().title, onPaymentIntent = { data -> - appViewModel.onScanResult(data) + appViewModel.onScanResult(data, allowPubkyAuth = false) }, onBlockedNavigation = { appViewModel.toast( diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt index b63d393506..42bffae193 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -75,6 +76,10 @@ fun PubkyAuthApprovalSheet( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + DisposableEffect(viewModel, authUrl) { + onDispose { viewModel.cancelLocalAuth(authUrl) } + } + LaunchedEffect(authUrl) { viewModel.load(authUrl) } Box { @@ -382,23 +387,47 @@ private fun ColumnScope.ApprovalDetails( Column(modifier = Modifier.weight(1f)) { VerticalSpacer(26.dp) - DescriptionText(serviceName = uiState.serviceName) - VerticalSpacer(8.dp) - BodyS( - text = stringResource(R.string.profile__auth_approval_requester, uiState.clientId), - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - VerticalSpacer(32.dp) + if (uiState.homeserverPublicKey != null) { + BodyM(text = stringResource(R.string.pubky_auth__signup_description), color = Colors.White64) + VerticalSpacer(16.dp) + } + if (uiState.permissions.isNotEmpty()) { + DescriptionText(serviceName = uiState.serviceName) + VerticalSpacer(8.dp) + } + if (uiState.clientId.isNotBlank()) { + BodyS( + text = stringResource(R.string.profile__auth_approval_requester, uiState.clientId), + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + VerticalSpacer(32.dp) + } else { + VerticalSpacer(24.dp) + } - PermissionsSection(permissions = uiState.permissions) + if (uiState.permissions.isNotEmpty()) { + PermissionsSection(permissions = uiState.permissions) + } FillHeight(min = 32.dp) TrustWarning() VerticalSpacer(16.dp) - uiState.profile?.let { ProfileCard(it) } + uiState.homeserverPublicKey?.let { homeserver -> + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .background(Colors.Gray6, RoundedCornerShape(16.dp)) + .padding(24.dp) + .testTag("PubkySignupHomeserver") + ) { + Text13Up(text = stringResource(R.string.pubky_auth__homeserver), color = Colors.White64) + BodyMSB(text = homeserver) + } + } ?: uiState.profile?.let { ProfileCard(it) } VerticalSpacer(16.dp) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt index 516ac57055..9d6eeb3157 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt @@ -24,6 +24,7 @@ import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError import to.bitkit.repositories.WatchOnlyAccountRepo @@ -90,6 +91,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( ApprovalState.Authorize }, clientId = request.clientId, + homeserverPublicKey = request.homeserverPublicKey, serviceName = serviceName, permissions = request.permissions.toImmutableList(), bitkitClaim = request.bitkitClaim, @@ -171,6 +173,10 @@ class PubkyAuthApprovalViewModel @Inject constructor( if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) + if (request.isSignup) { + _effects.emit(PubkyAuthApprovalEffect.Dismiss) + return + } _uiState.update { state -> if (state.authUrl == authUrl) state.copy(state = ApprovalState.Success) else state } @@ -179,6 +185,21 @@ class PubkyAuthApprovalViewModel @Inject constructor( private suspend fun approveRequest( request: PubkyAuthRequest, authUrl: String, + ): Boolean = if (request.isSignup) { + pubkyRepo.approveSignupAuth(request).fold( + onSuccess = { true }, + onFailure = { + handleApprovalFailure(it, authUrl) + false + }, + ) + } else { + approveSignInRequest(request, authUrl) + } + + private suspend fun approveSignInRequest( + request: PubkyAuthRequest, + authUrl: String, ): Boolean { val preparedClaim = runSuspendCatching { if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { @@ -246,9 +267,11 @@ class PubkyAuthApprovalViewModel @Inject constructor( private fun resetForLoad(authUrl: String): Boolean { while (true) { val currentState = _uiState.value + val isAuthorizing = currentState.state == ApprovalState.Authorizing && + inFlightAuthorization.get()?.authUrl == authUrl if ( currentState.authUrl == authUrl && - currentState.state in setOf(ApprovalState.Authenticating, ApprovalState.Authorizing) + (currentState.state == ApprovalState.Authenticating || isAuthorizing) ) { return false } @@ -273,8 +296,16 @@ class PubkyAuthApprovalViewModel @Inject constructor( } private suspend fun handleApprovalFailure(error: Throwable, authUrl: String) { - Logger.error("Auth approval failed", error, context = TAG) + if (error !is PubkyAlreadySignedInError) Logger.error("Auth approval failed", error, context = TAG) if (_uiState.value.authUrl != authUrl) return + if (error is PubkyAlreadySignedInError) { + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.pubky_auth__already_signed_in), + ) + _effects.emit(PubkyAuthApprovalEffect.Dismiss) + return + } _uiState.update { it.copy(state = ApprovalState.Authorize) } ToastEventBus.send( type = Toast.ToastType.ERROR, @@ -299,6 +330,7 @@ data class PubkyAuthApprovalUiState( val authUrl: String = "", val state: ApprovalState = ApprovalState.Loading, val clientId: String = "", + val homeserverPublicKey: String? = null, val serviceName: String = "", val permissions: ImmutableList = persistentListOf(), val bitkitClaim: PubkyAuthClaim? = null, diff --git a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt index 580cf89ff4..b254013094 100644 --- a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt +++ b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt @@ -4,27 +4,31 @@ import android.content.Context import to.bitkit.R import to.bitkit.models.PubkyAuthRequestError import to.bitkit.repositories.WatchOnlyAccountError +import to.bitkit.services.PubkyRingAuthTimeoutError fun Throwable.localizedPubkyAuthMessage(context: Context): String? { var current: Throwable? = this while (current != null) { - val messageResource = when (current) { - is PubkyAuthRequestError.InvalidUrl -> R.string.profile__auth_error_invalid_url - PubkyAuthRequestError.RequesterChanged -> R.string.profile__auth_error_invalid_url - PubkyAuthRequestError.MissingBitkitClaim -> R.string.profile__auth_error_missing_claim - PubkyAuthRequestError.DuplicateBitkitClaim -> R.string.profile__auth_error_duplicate_claim - is PubkyAuthRequestError.UnsupportedBitkitClaim -> R.string.profile__auth_error_unsupported_claim - PubkyAuthRequestError.InvalidBitkitClaimCapabilities -> R.string.profile__auth_error_invalid_capabilities - is WatchOnlyAccountError.AuthorizationAccountMissing -> R.string.watch_only_accounts__setup_not_finished - is WatchOnlyAccountError.InvalidAccountName -> R.string.watch_only_accounts__error_invalid_name - is WatchOnlyAccountError.InvalidExtendedPublicKey -> R.string.watch_only_accounts__error_invalid_xpub - is WatchOnlyAccountError.NodeUnavailable -> R.string.watch_only_accounts__error_node_unavailable - else -> null - } + val messageResource = current.pubkyAuthMessageResource() if (messageResource != null) { return context.getString(messageResource) } current = current.cause } - return message + return context.getString(R.string.common__error_body) +} + +private fun Throwable.pubkyAuthMessageResource() = when (this) { + is PubkyRingAuthTimeoutError -> R.string.profile__auth_error_timeout + is PubkyAuthRequestError.InvalidUrl -> R.string.profile__auth_error_invalid_url + PubkyAuthRequestError.RequesterChanged -> R.string.profile__auth_error_invalid_url + PubkyAuthRequestError.MissingBitkitClaim -> R.string.profile__auth_error_missing_claim + PubkyAuthRequestError.DuplicateBitkitClaim -> R.string.profile__auth_error_duplicate_claim + is PubkyAuthRequestError.UnsupportedBitkitClaim -> R.string.profile__auth_error_unsupported_claim + PubkyAuthRequestError.InvalidBitkitClaimCapabilities -> R.string.profile__auth_error_invalid_capabilities + is WatchOnlyAccountError.AuthorizationAccountMissing -> R.string.watch_only_accounts__setup_not_finished + is WatchOnlyAccountError.InvalidAccountName -> R.string.watch_only_accounts__error_invalid_name + is WatchOnlyAccountError.InvalidExtendedPublicKey -> R.string.watch_only_accounts__error_invalid_xpub + is WatchOnlyAccountError.NodeUnavailable -> R.string.watch_only_accounts__error_node_unavailable + else -> null } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b80600e2b9..d935f27328 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -116,6 +116,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkyRingAuthCallback @@ -132,6 +133,7 @@ import to.bitkit.models.WalletScope import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.sanitizedDeeplinkLogValue +import to.bitkit.models.sanitizedQrLogValue import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork import to.bitkit.models.toTxType @@ -188,6 +190,7 @@ import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.ScreenDeepLinks +import to.bitkit.ui.utils.localizedPubkyAuthMessage import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -417,6 +420,8 @@ class AppViewModel @Inject constructor( } } + private val toastManager = toastManagerProvider(viewModelScope) + init { viewModelScope.launch { ToastEventBus.events.collect { @@ -1854,7 +1859,7 @@ class AppViewModel @Inject constructor( // Skip validation for empty input if (valueWithoutSpaces.isEmpty()) return - if (valueWithoutSpaces.startsWith("$PUBKYAUTH_SCHEME://", ignoreCase = true)) return + if (PubkyAuthRequest.isProtocolUrl(valueWithoutSpaces)) return if (PubkyPublicKeyFormat.normalized(valueWithoutSpaces) != null) { if (isPaykitEnabled.value) { @@ -2066,6 +2071,7 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean = false, contactPaymentContext: ContactPaymentContext? = null, preserveUntilComplete: Boolean = false, + allowPubkyAuth: Boolean = isMainScanner, ): Job? { if (!_isAuthenticated.value) { enqueueDeferredScan( @@ -2074,13 +2080,13 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) return null } val normalized = data.removeLightningSchemes() val scanId = scanLogId(data) - val scheduled = scheduledScan val isSameActiveScan = normalized == scheduled?.normalizedInput && scheduled.job.isActive && @@ -2091,16 +2097,20 @@ class AppViewModel @Inject constructor( } if (scheduled?.job?.isActive == true && scheduled.mustComplete) { - enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) + enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth) return null } val previousJob = scheduled?.job val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { scanMutex.withLock { - setActiveContactPaymentContext(contactPaymentContext) + if (!awaitPubkyDeeplinkInitialization(source, data, allowPubkyAuth)) return@withLock + if (deferLockedScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth)) { + return@withLock + } + prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext) if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys) + handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth) } } val nextScheduledScan = ScheduledScan( @@ -2126,8 +2136,41 @@ class AppViewModel @Inject constructor( return nextJob } + private suspend fun awaitPubkyDeeplinkInitialization( + source: ScanSource, + data: String, + allowPubkyAuth: Boolean, + ): Boolean { + if (source != ScanSource.DEEPLINK || !allowPubkyAuth) return true + if (!PubkyAuthRequest.isProtocolUrl(data)) return true + + if (!PubkyAuthRequest.isSignupUrl(data)) pubkyRepo.awaitInitialization() + return isPaykitUiEnabledFromSettings() && walletRepo.walletExists() + } + + private suspend fun isPaykitUiEnabledFromSettings() = + PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) + + private fun deferLockedScan( + source: ScanSource, + data: String, + startDelay: Duration, + routePubkyKeys: Boolean, + contactPaymentContext: ContactPaymentContext?, + allowPubkyAuth: Boolean, + ): Boolean { + if (_isAuthenticated.value) return false + + synchronized(deferredScanLock) { + if (deferredScan == null) { + enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth) + } + } + return true + } + private fun scanLogId(data: String): String { - val scanLogInput = SamRockSetupRequest.sanitizedDescription(data.removeLightningSchemes()) ?: data + val scanLogInput = data.removeLightningSchemes().sanitizedQrLogValue() return if (scanLogInput.length > SCAN_LOG_ID_MAX_LENGTH) { "${scanLogInput.take(SCAN_LOG_ID_AFFIX_LENGTH)}…${scanLogInput.takeLast(SCAN_LOG_ID_AFFIX_LENGTH)}" } else { @@ -2141,6 +2184,7 @@ class AppViewModel @Inject constructor( startDelay: Duration, routePubkyKeys: Boolean, contactPaymentContext: ContactPaymentContext?, + allowPubkyAuth: Boolean, ) { val scanId = scanLogId(data) val normalized = data.removeLightningSchemes() @@ -2154,6 +2198,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) return } @@ -2172,6 +2217,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) } Logger.info("Queuing '${source.label}' scan for deferred handling: '$scanId'", context = TAG) @@ -2210,6 +2256,7 @@ class AppViewModel @Inject constructor( routePubkyKeys = pending.routePubkyKeys, contactPaymentContext = pending.contactPaymentContext, preserveUntilComplete = true, + allowPubkyAuth = pending.allowPubkyAuth, ) } @@ -2561,6 +2608,7 @@ class AppViewModel @Inject constructor( startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, contactPaymentContext: ContactPaymentContext? = null, + allowPubkyAuth: Boolean = isMainScanner, ) { launchScan( source = ScanSource.SCAN_RESULT, @@ -2568,6 +2616,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) } @@ -2590,6 +2639,7 @@ class AppViewModel @Inject constructor( source = ScanSource.SCAN_RESULT, data = paymentRequest, contactPaymentContext = context, + allowPubkyAuth = false, ) } @@ -2607,7 +2657,13 @@ class AppViewModel @Inject constructor( private suspend fun handleScan( result: String, routePubkyKeys: Boolean, + contactPaymentContext: ContactPaymentContext?, + allowPubkyAuth: Boolean, ) = withContext(bgDispatcher) { + if (rejectPubkyAuthScan(result, allowPubkyAuth, contactPaymentContext)) return@withContext + + val input = result.removeLightningSchemes() + val contactPaymentProfile = activeContactPaymentProfile() val incomingPaymentRequest = activeIncomingPaymentRequest() val isPaymentRequest = incomingPaymentRequest != null @@ -2627,7 +2683,6 @@ class AppViewModel @Inject constructor( resetQuickPay() val fromMainScanner = isMainScanner - val input = result.removeLightningSchemes() // TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63 if (Bip21Utils.isDuplicatedBip21(input)) { @@ -2652,16 +2707,9 @@ class AppViewModel @Inject constructor( return@withContext } - if (input.startsWith("$PUBKYAUTH_SCHEME://", ignoreCase = true)) { + if (PubkyAuthRequest.isProtocolUrl(input)) { clearActiveContactPaymentContext() - if (!fromMainScanner) { - hideSheet() - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__qr_error_header), - description = context.getString(R.string.other__qr_error_text), - ) - } else if (isPaykitEnabled.value) { + if (isPaykitUiEnabledFromSettings()) { handlePubkyAuth(input) } else { hideSheet() @@ -2791,12 +2839,7 @@ class AppViewModel @Inject constructor( if (interruptedRequest == null) return if (!retryIncomingRequest) { - paymentRequestPresentationGeneration++ - if (requestedPaymentRequestId == interruptedRequest.id) { - requestedPaymentRequestId = null - } - clearPaymentRequestPresentationRetry(interruptedRequest.id) - viewModelScope.launch { paykitPaymentRequestRepo.markPresented(interruptedRequest) } + viewModelScope.launch { markIncomingPaymentRequestPresented(interruptedRequest) } return } @@ -2809,6 +2852,42 @@ class AppViewModel @Inject constructor( isSubmittingPaymentRequest = false } + private suspend fun rejectPubkyAuthScan( + input: String, + allowPubkyAuth: Boolean, + contactPaymentContext: ContactPaymentContext?, + ): Boolean { + val unwrappedInput = input.removeLightningSchemes() + if (!PubkyAuthRequest.isProtocolUrl(unwrappedInput)) return false + if (allowPubkyAuth && input == unwrappedInput) return false + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.other__qr_error_text), + ) + clearRejectedContactPaymentContext(contactPaymentContext) + return true + } + + private suspend fun clearRejectedContactPaymentContext(context: ContactPaymentContext?) { + if (context == null) return + synchronized(contactPaymentContextLock) { + if (activeContactPaymentContext != context) return + activeContactPaymentContext = null + preparedContactPaymentContext = null + } + context.incomingPaymentRequest?.let { markIncomingPaymentRequestPresented(it) } + } + + private suspend fun markIncomingPaymentRequestPresented(request: PaykitPaymentRequest) { + paymentRequestPresentationGeneration++ + if (requestedPaymentRequestId == request.id) { + requestedPaymentRequestId = null + } + clearPaymentRequestPresentationRetry(request.id) + paykitPaymentRequestRepo.markPresented(request) + } + private fun setActiveContactPaymentContext(context: ContactPaymentContext?) { synchronized(contactPaymentContextLock) { if (activeContactPaymentContext != context) preparedContactPaymentContext = null @@ -2816,6 +2895,15 @@ class AppViewModel @Inject constructor( } } + private fun prepareContactPaymentContextForScan( + input: String, + allowPubkyAuth: Boolean, + context: ContactPaymentContext?, + ) { + val preservesExistingContext = PubkyAuthRequest.isProtocolUrl(input) && !allowPubkyAuth && context == null + if (!preservesExistingContext) setActiveContactPaymentContext(context) + } + private fun clearPendingContactPaymentContext(paymentHash: String) { synchronized(contactPaymentContextLock) { pendingContactPaymentContexts.remove(paymentHash) @@ -4458,7 +4546,6 @@ class AppViewModel @Inject constructor( // endregion // region Toasts - private val toastManager = toastManagerProvider(viewModelScope) val currentToast: StateFlow = toastManager.currentToast fun toast( @@ -5120,9 +5207,13 @@ class AppViewModel @Inject constructor( return@launch } - if (uri.scheme == PUBKYAUTH_SCHEME) { - if (!isPaykitEnabled.value) return@launch - handlePubkyAuth(uri.toString()) + if (PubkyAuthRequest.isProtocolUrl(value)) { + launchScan( + source = ScanSource.DEEPLINK, + data = value, + startDelay = SCREEN_TRANSITION_DELAY, + allowPubkyAuth = true, + ) return@launch } @@ -5144,7 +5235,10 @@ class AppViewModel @Inject constructor( } private suspend fun handlePubkyAuth(authUrl: String) { - if (pubkyRepo.publicKey.value == null) { + val isSignup = PubkyAuthRequest.isSignupUrl(authUrl) + if (isSignup && rejectPubkySignupForExistingIdentity()) return + + if (!isSignup && pubkyRepo.publicKey.value == null) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.pubky_auth__no_identity), @@ -5153,7 +5247,7 @@ class AppViewModel @Inject constructor( return } - if (!pubkyRepo.hasSecretKey()) { + if (!isSignup && !pubkyRepo.hasSecretKey()) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.profile__auth_approval_ring_only), @@ -5163,6 +5257,24 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } + private suspend fun rejectPubkySignupForExistingIdentity(): Boolean { + val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() }.getOrElse { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__auth_error_title), + description = it.localizedPubkyAuthMessage(context), + ) + return true + } + if (!hasIdentity) return false + + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.pubky_auth__already_signed_in), + ) + return true + } + private suspend fun handlePubkyRingAuthCallback(callback: PubkyRingAuthCallback) { when (val result = pubkyRepo.handleAuthCallback(callback)) { is PubkyRingAuthCallbackHandlingResult.TrustedError -> { @@ -5247,7 +5359,6 @@ class AppViewModel @Inject constructor( private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val BITKIT_SCHEME = "bitkit" - private const val PUBKYAUTH_SCHEME = "pubkyauth" private const val RECOVERY_MODE_DEEPLINK = "recovery-mode" /** Max characters kept in a scan log id before truncating. */ @@ -5284,6 +5395,7 @@ private data class DeferredScan( val startDelay: Duration, val routePubkyKeys: Boolean, val contactPaymentContext: ContactPaymentContext?, + val allowPubkyAuth: Boolean, ) // region send contract diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index e406efd3e6..4afc065826 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -130,6 +130,9 @@ class SettingsViewModel @Inject constructor( val hasSeenProfileIntro = settingsStore.data.map { it.hasSeenProfileIntro } .asStateFlow(initialValue = false) + val isPubkyProfileSetupPending = settingsStore.isPubkyProfileSetupPending + .asStateFlow(initialValue = false) + fun setHasSeenProfileIntro(value: Boolean) { viewModelScope.launch { settingsStore.update { it.copy(hasSeenProfileIntro = value) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b8d50eb58..b90d2dfa71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -623,6 +623,7 @@ The requested access does not match this Bitkit claim. This Pubky authorization link is invalid. This authorization is missing its required Bitkit claim. + The authorization relay did not respond in time. Please try again. Authorization Failed This Bitkit claim is not supported. Failed to read selected image @@ -684,8 +685,11 @@ Suggestions To Add Your Name Your Pubky + Already signed in + Homeserver Pubky Identity Required Create a Pubky identity in your profile to approve auth requests. + Create a new Pubky identity on this homeserver. Only continue if you trust it. Back Up Now that you have some funds in your wallet, it is time to back up your money! There are no funds in your wallet yet, but you can create a backup if you wish. diff --git a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt index f67dfc878f..1a3d1f7270 100644 --- a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt +++ b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt @@ -1,5 +1,14 @@ package to.bitkit.build +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import org.w3c.dom.Element import java.nio.file.Path import javax.xml.parsers.DocumentBuilderFactory @@ -10,6 +19,8 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [34]) class PubkyAuthManifestTest { private val repoRoot = generateSequence( Path(requireNotNull(System.getProperty("user.dir")) { "user.dir is required" }), @@ -27,14 +38,92 @@ class PubkyAuthManifestTest { } @Test - fun `pubkyauth alias is disabled by default`() { - val alias = manifest.getElementsByTagName("activity-alias").elements() - .single { it.getAttribute("android:name") == ".ui.MainActivityPubkyAuth" } - - assertEquals(".ui.MainActivity", alias.getAttribute("android:targetActivity")) - assertEquals("false", alias.getAttribute("android:enabled")) - assertEquals("true", alias.getAttribute("android:exported")) - assertTrue(alias.handlesScheme("pubkyauth")) + fun `Pubky aliases are disabled by default`() { + val aliases = manifest.getElementsByTagName("activity-alias").elements() + listOf(".ui.MainActivityPubkyAuth", ".ui.MainActivityPubkySignup").forEach { name -> + val alias = aliases.single { it.getAttribute("android:name") == name } + assertEquals(".ui.MainActivity", alias.getAttribute("android:targetActivity")) + assertEquals("false", alias.getAttribute("android:enabled")) + assertEquals("true", alias.getAttribute("android:exported")) + assertTrue(alias.handlesScheme("pubkyauth")) + } + } + + @Test + fun `signup and authorization links resolve only through their enabled aliases`() { + val application = ApplicationProvider.getApplicationContext() + val packageManager = application.packageManager + val authAlias = ComponentName(application.packageName, "to.bitkit.ui.MainActivityPubkyAuth") + val signupAlias = ComponentName(application.packageName, "to.bitkit.ui.MainActivityPubkySignup") + val signupUrls = listOf( + "pubkyring://signup?hs=homeserver", + "pubkyauth://signup?hs=homeserver&relay=relay&secret=secret&caps=rw", + "pubkyauth://signup?hs=homeserver", + "pubkyauth://direct_signup?hs=homeserver", + ) + val authUrls = listOf("pubkyauth://signin_grant?caps=rw", "pubkyauth://signup_grant?caps=rw") + val unrelatedUrls = listOf( + "pubkyring://auth", + "pubkyring://direct_signup", + "pubkyauth://?caps=rw", + "pubkyauth://signin", + "pubkyauth://signin?caps=rw", + "pubkyauth://grant?caps=rw", + "pubkyauth://session", + "pubkyauth://secret_export", + ) + val allUrls = signupUrls + authUrls + unrelatedUrls + + assertRoutes(packageManager, application.packageName, allUrls, null) + packageManager.setComponentEnabledSetting( + signupAlias, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP, + ) + assertRoutes(packageManager, application.packageName, signupUrls, signupAlias) + assertRoutes(packageManager, application.packageName, authUrls + unrelatedUrls, null) + + packageManager.setComponentEnabledSetting( + signupAlias, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP, + ) + packageManager.setComponentEnabledSetting( + authAlias, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP, + ) + assertRoutes(packageManager, application.packageName, authUrls, authAlias) + assertRoutes(packageManager, application.packageName, signupUrls + unrelatedUrls, null) + + packageManager.setComponentEnabledSetting( + authAlias, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP, + ) + assertRoutes(packageManager, application.packageName, allUrls, null) + } + + private fun assertRoutes( + packageManager: PackageManager, + packageName: String, + urls: List, + alias: ComponentName?, + ) { + urls.forEach { + val intent = Intent(Intent.ACTION_VIEW, it.toUri()) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setPackage(packageName) + val resolved = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + if (alias == null) { + assertTrue(resolved.isEmpty(), it) + } else { + val activity = resolved.single().activityInfo + assertEquals(alias.className, activity.name, it) + assertEquals("to.bitkit.ui.MainActivity", activity.targetActivity) + assertTrue(activity.exported) + } + } } private fun parseManifest(path: Path) = DocumentBuilderFactory.newInstance() diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 1577dd834e..38afcab01f 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -1,13 +1,60 @@ package to.bitkit.models +import java.net.URLEncoder import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue class PubkyAuthRequestTest { + @Test + fun `parse authorized signup preserves registration and authorization details`() { + listOf("pubkyring", "pubkyauth").forEach { scheme -> + val request = PubkyAuthRequest.parseSignup(ringSignupUrl("invite code", scheme)).getOrThrow() + + assertTrue(request.isSignup) + assertEquals("homeserver", request.homeserverPublicKey) + assertEquals("invite code", request.signupToken) + assertEquals("https://relay.example/inbox/", request.relay) + assertEquals("/pub/example.app/:rw", request.capabilities) + assertEquals( + "pubkyauth:///?relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw", + request.authorizationUrl, + ) + } + } + + @Test + fun `parse direct signup accepts canonical and legacy formats`() { + listOf("direct_signup", "signup").forEach { action -> + val request = PubkyAuthRequest.parseSignup(directSignupUrl(action, "invite code")).getOrThrow() + + assertTrue(request.isSignup) + assertEquals("homeserver", request.homeserverPublicKey) + assertEquals("invite code", request.signupToken) + assertEquals("", request.relay) + assertEquals("", request.capabilities) + assertNull(request.authorizationUrl) + } + } + + @Test + fun `parse Ring signup rejects missing and duplicate required values`() { + val invalidUrls = listOf( + ringSignupUrl().replace("&secret=secret", ""), + "${ringSignupUrl()}&hs=other", + directSignupUrl("signup") + "&relay=https%3A%2F%2Frelay.example", + ) + + invalidUrls.forEach { url -> + assertIs(PubkyAuthRequest.parseSignup(url).exceptionOrNull()) + } + } + @Test fun `parse recognizes watch-only account claim`() { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES @@ -50,6 +97,7 @@ class PubkyAuthRequestTest { capabilities = "/pub/bitkit.to/:rw", ).getOrThrow() + assertFalse(request.isSignup) assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } @@ -261,4 +309,14 @@ class PubkyAuthRequestTest { } return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims" } + + private fun ringSignupUrl(signupToken: String? = null, scheme: String = "pubkyring"): String = + "$scheme://signup?hs=homeserver" + + "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw" + + signupToken?.let { "&st=${URLEncoder.encode(it, Charsets.UTF_8.name())}" }.orEmpty() + + private fun directSignupUrl(action: String, signupToken: String? = null): String = + "pubkyauth://$action?hs=homeserver" + + signupToken?.let { "&st=${URLEncoder.encode(it, Charsets.UTF_8.name())}" }.orEmpty() } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 444a811df9..547bf1e47c 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -9,6 +9,7 @@ import com.synonym.paykit.ContactProfileSource import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PubkyAuthCompanionClaim +import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicationStatus import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine @@ -20,6 +21,8 @@ import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -40,12 +43,15 @@ import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyRingAuthCallback import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 +import to.bitkit.services.PubkyRingAuthTimeoutError import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError @@ -74,12 +80,18 @@ class PubkyRepoTest : BaseUnitTest() { private val pubkyStore = mock() private val settingsStore = mock() private val settingsFlow = MutableStateFlow(SettingsData()) + private val profileSetupPending = MutableStateFlow(false) @Before fun setUp() = runBlocking { settingsFlow.value = SettingsData() whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData())) whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(settingsStore.isPubkyProfileSetupPending).thenReturn(profileSetupPending) + whenever { settingsStore.setPubkyProfileSetupPending(any()) }.thenAnswer { + profileSetupPending.value = it.getArgument(0) + Unit + } whenever(pubkyService.contactRecords()).thenReturn(emptyList()) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) @@ -105,6 +117,137 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isAuthenticated.value) } + @Test + fun `Ring signup registers and authorizes before activating the local session`() = test { + val events = mutableListOf() + val registeredSession = mock() + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenAnswer { + events += "register" + registeredSession + } + whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")).thenAnswer { + events += "authorize" + } + whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { events += "activate" } + + val result = sut.approveSignupAuth(request) + + assertTrue(result.isSuccess) + assertEquals(listOf("register", "authorize", "activate"), events) + verifyBlocking(pubkyService, never()) { signIn(any()) } + assertTrue(profileSetupPending.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + + @Test + fun `Ring signup does not activate the registered session when authorization fails`() = test { + val registeredSession = mock() + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")) + .thenThrow(IllegalStateException("authorization failed")) + + assertTrue(sut.approveSignupAuth(request).isFailure) + verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) } + assertFalse(profileSetupPending.value) + assertNull(sut.publicKey.value) + } + + @Test + fun `Ring signup can retry after relay timeout without activating the timed out session`() = test { + val registeredSession = mock() + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")) + .thenAnswer { throw AppError(PubkyRingAuthTimeoutError()) } + .thenReturn(Unit) + + assertTrue(sut.approveSignupAuth(request).isFailure) + verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) } + assertNull(sut.publicKey.value) + assertFalse(profileSetupPending.value) + + assertTrue(sut.approveSignupAuth(request).isSuccess) + verifyBlocking(pubkyService) { activateRegisteredIdentity(registeredSession) } + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertTrue(profileSetupPending.value) + } + + @Test + fun `Ring signup clears profile setup state when local activation fails`() = test { + val registeredSession = mock() + val request = ringSignupRequest() + profileSetupPending.value = true + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { + throw TestAppError("activation failed") + } + + assertTrue(sut.approveSignupAuth(request).isFailure) + assertFalse(profileSetupPending.value) + assertNull(sut.publicKey.value) + } + + @Test + fun `direct signup skips app authorization and activates the registered session`() = test { + val registeredSession = mock() + val request = directSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + + assertTrue(sut.approveSignupAuth(request).isSuccess) + verifyBlocking(pubkyService, never()) { approveRingAuth(any(), any(), any()) } + verifyBlocking(pubkyService) { activateRegisteredIdentity(registeredSession) } + assertTrue(profileSetupPending.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + + @Test + fun `Ring signup stops when registration fails`() = test { + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")) + .thenThrow(IllegalStateException("registration failed")) + + assertTrue(sut.approveSignupAuth(request).isFailure) + verifyBlocking(pubkyService, never()) { approveRingAuth(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) } + assertFalse(profileSetupPending.value) + } + + @Test + fun `Ring signup clears credentials when pending setup persistence and rollback fail`() = test { + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")) + .thenReturn(mock()) + whenever(settingsStore.setPubkyProfileSetupPending(true)).thenAnswer { + throw TestAppError("persistence failed") + } + whenever(pubkyService.forgetSessionAccess()).thenAnswer { + throw TestAppError("cleanup failed") + } + + assertTrue(sut.approveSignupAuth(ringSignupRequest()).isFailure) + + verify(keychain).delete(Keychain.Key.PAYKIT_SESSION.name) + verify(keychain).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + assertFalse(profileSetupPending.value) + } + + @Test + fun `identity check fails closed when secure storage cannot be read`() = test { + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenThrow(IllegalStateException("unavailable")) + + assertTrue(runSuspendCatching { sut.hasIdentity() }.isFailure) + } + @Test fun `startAuthentication should return auth uri on success`() = test { val authUri = "pubky://auth?capabilities=..." @@ -537,6 +680,186 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { forgetSessionAccess() } } + @Test + fun `createIdentity clears stale pending signup without a session`() = test { + profileSetupPending.value = true + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn(null) + + val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) + + assertTrue(result.isFailure) + assertFalse(profileSetupPending.value) + verify(keychain).loadString(Keychain.Key.BIP39_MNEMONIC.name) + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + } + + @Test + fun `createIdentity restores a stored local key without Homegate signup`() = test { + val httpClient = identityHttpClient() + sut = createSut(httpClient) + profileSetupPending.value = true + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret") + whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + + val result = sut.createIdentity("Restored", "", emptyList(), emptyList(), null) + + assertTrue(result.isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals("Restored", sut.profile.value?.name) + assertFalse(profileSetupPending.value) + assertTrue((httpClient.engine as MockEngine).requestHistory.isEmpty()) + verifyBlocking(pubkyService) { signIn("local-secret") } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verify(keychain, never()).loadString(Keychain.Key.BIP39_MNEMONIC.name) + httpClient.close() + } + + @Test + fun `createIdentity stops when the local key cannot be read`() = test { + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenAnswer { + throw TestAppError("unavailable") + } + + val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) + + assertEquals("unavailable", result.exceptionOrNull()?.message) + verify(keychain, never()).loadString(Keychain.Key.BIP39_MNEMONIC.name) + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + } + + @Test + fun `createIdentity retries local sign in without deleting the existing identity`() = test { + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret") + whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.signIn("local-secret")).thenAnswer { throw TestAppError("offline") }.thenReturn(Unit) + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + + val firstResult = sut.createIdentity("Test", "", emptyList(), emptyList(), null) + + assertEquals("offline", firstResult.exceptionOrNull()?.message) + assertNull(sut.publicKey.value) + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + + assertTrue(sut.createIdentity("Test", "", emptyList(), emptyList(), null).isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verifyBlocking(pubkyService, times(2)) { signIn("local-secret") } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } + verify(keychain, never()).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + } + + @Test + fun `createIdentity preserves the existing identity when profile publication fails`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + val existingProfile = sut.profile.value + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret") + whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.publishPaykitProfile(any())) + .thenAnswer { throw TestAppError("offline") } + .thenReturn(mock()) + + val firstResult = sut.createIdentity("Updated", "", emptyList(), emptyList(), null) + + assertEquals("offline", firstResult.exceptionOrNull()?.message) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals(existingProfile, sut.profile.value) + assertTrue(sut.isAuthenticated.value) + + assertTrue(sut.createIdentity("Updated", "", emptyList(), emptyList(), null).isSuccess) + verifyBlocking(pubkyService, times(2)) { signIn("local-secret") } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } + verify(keychain, never()).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + } + + @Test + fun `createIdentity preserves local recovery after cancellation`() = test { + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local-secret") + whenever(pubkyService.publicKeyFromSecret("local-secret")).thenReturn(VALID_SELF_KEY) + var cancelDuringSignIn: Boolean? = true + var operationStarted = CompletableDeferred() + whenever(pubkyService.signIn("local-secret")).doSuspendableAnswer { + if (cancelDuringSignIn == true) { + operationStarted.complete(Unit) + awaitCancellation() + } + Unit + } + whenever(pubkyService.publishPaykitProfile(any())).doSuspendableAnswer { + if (cancelDuringSignIn == false) { + operationStarted.complete(Unit) + awaitCancellation() + } + mock() + } + for (duringSignIn in listOf(true, false)) { + cancelDuringSignIn = duringSignIn + operationStarted = CompletableDeferred() + + val result = async { sut.createIdentity("Test", "", emptyList(), emptyList(), null) } + operationStarted.await() + result.cancelAndJoin() + + assertTrue(result.isCancelled) + assertNull(sut.publicKey.value) + } + + cancelDuringSignIn = null + assertTrue(sut.createIdentity("Test", "", emptyList(), emptyList(), null).isSuccess) + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } + verify(keychain, never()).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + } + + @Test + fun `createIdentity signs up when a Ring session has no local key`() = test { + val httpClient = identityHttpClient() + sut = createSut(httpClient) + stubSignupKeys() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("ring-session") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("") + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + + val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) + + assertTrue(result.isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verifyBlocking(pubkyService) { signUp("secret", "test-homeserver", "test-code") } + verifyBlocking(pubkyService, never()) { signIn(any()) } + httpClient.close() + } + + @Test + fun `createIdentity should preserve signup session when pending profile publication fails`() = test { + val registeredSession = mock() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + assertTrue(sut.approveSignupAuth(ringSignupRequest()).isSuccess) + clearInvocations(pubkyService) + whenever(pubkyService.publishPaykitProfile(any())).thenAnswer { throw TestAppError("Publish failed") } + + val result = sut.createIdentity( + name = "Test", + bio = "", + links = emptyList(), + tags = emptyList(), + avatarBytes = null, + ) + + assertTrue(result.isFailure) + verifyBlocking(pubkyService) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { signOut() } + assertTrue(profileSetupPending.value) + } + @Test fun `createIdentity should keep session when canceled during contact load`() = test { val contactsLoadStarted = CompletableDeferred() @@ -972,6 +1295,36 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(result.getOrNull()) } + @Test + fun `awaitInitialization shares startup and preserves it when a waiter is cancelled`() = test { + val imported = CompletableDeferred() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + whenever(pubkyService.importSession("saved_session")).doSuspendableAnswer { imported.await() } + val repo = createSut() + val cancelledWaiter = async { repo.awaitInitialization() } + val waiter = async { repo.awaitInitialization() } + + assertFalse(waiter.isCompleted) + assertNull(repo.publicKey.value) + cancelledWaiter.cancelAndJoin() + imported.complete(VALID_SELF_KEY) + waiter.await() + + assertEquals(VALID_SELF_KEY, repo.publicKey.value) + verify(pubkyService).importSession("saved_session") + } + + @Test + fun `awaitInitialization completes without identity after startup failure`() = test { + whenever(pubkyService.initialize()).thenAnswer { throw TestAppError("Startup failed") } + val repo = createSut() + + repo.awaitInitialization() + + assertNull(repo.publicKey.value) + verify(pubkyService, never()).importSession(any()) + } + @Test fun `initialize should restore saved session with prefixed public key`() = test { val session = "saved_session" @@ -1563,6 +1916,21 @@ class PubkyRepoTest : BaseUnitTest() { status = status, ) + private suspend fun stubSignupKeys() { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("seed words") + whenever(pubkyService.deriveSecretKey("seed words")).thenReturn("secret") + whenever(pubkyService.publicKeyFromSecret("secret")).thenReturn(VALID_SELF_KEY) + } + + private fun ringSignupRequest() = PubkyAuthRequest.parseSignup( + "pubkyring://signup?hs=homeserver&relay=https%3A%2F%2Frelay.example" + + "&secret=request&caps=%2Fpub%2Fexample%2F%3Arw&st=invite", + ).getOrThrow() + + private fun directSignupRequest() = PubkyAuthRequest.parseSignup( + "pubkyauth://direct_signup?hs=homeserver&st=invite", + ).getOrThrow() + private fun createPaykitProfile( name: String, bio: String = "", diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 5a74b7f34c..005e78b71e 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -2,16 +2,29 @@ package to.bitkit.services import com.synonym.paykit.EncryptedLinkRecoveryMarkerPolicy import com.synonym.paykit.EndpointManagementScope +import com.synonym.paykit.PaykitSdk import com.synonym.paykit.PubkyClientConfig +import com.synonym.paykit.PubkyLocalSecretKey +import com.synonym.paykit.PubkySessionAccess +import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicContactSharingPolicy +import com.synonym.paykit.ReceiverNoiseSecretKey +import kotlinx.coroutines.test.runTest import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.ext.fromHex import to.bitkit.ext.toHex import to.bitkit.models.PubkyAuthRequestError import to.bitkit.utils.AppError +import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -19,6 +32,66 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class PaykitSdkServiceTest { + @Test + fun `registered identity activation persists credentials or clears partial activation`() = runTest { + for (failure in listOf(null, "session", "secret", "initialize", "cancel")) { + val keychain = mock() + val blocking = mock() + whenever(keychain.accessBlocking(any())).doAnswer { + it.getArgument Any?>(0).invoke(blocking) + } + val bytes = ByteArray(32) { 1 } + whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)).thenReturn(bytes) + val sdk = mock() + whenever(sdk.contactRecords()).thenReturn(emptyList()) + val access = mock() + val secret = mock() + val noise = mock() + whenever(secret.exportBytes()).thenReturn(bytes) + whenever(noise.exportBytes()).thenReturn(bytes) + whenever(access.exportSessionSecret()).thenReturn("new-session") + whenever(access.exportLocalSecretKey()).thenReturn(secret) + whenever(access.exportReceiverNoiseSecretKey()).thenReturn(noise) + val error = if (failure == "cancel") { + CancellationException("cancelled") + } else { + IllegalStateException("activation failed") + } + when (failure) { + "session" -> whenever(keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, "new-session")) + .thenThrow(error) + "secret" -> whenever(keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, bytes.toHex())) + .thenThrow(error) + "initialize", "cancel" -> whenever(sdk.initialize()).thenThrow(error) + } + var handlesCreated = 0 + val service = PaykitSdkService(mock(), keychain) { + handlesCreated++ + sdk + } + val result = PubkySessionBootstrapResult(access, "pubky_test") + + if (failure == null) { + service.activateRegisteredIdentity(result) + inOrder(keychain, sdk) { + verify(keychain).upsertString(Keychain.Key.PAYKIT_SESSION.name, "new-session") + verify(keychain).upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, bytes.toHex()) + verify(sdk).initialize() + } + verify(blocking, never()).delete(any()) + } else { + val thrown = assertFailsWith(error::class) { service.activateRegisteredIdentity(result) } + assertEquals(error, thrown) + verify(blocking).delete(Keychain.Key.PAYKIT_SESSION.name) + verify(blocking).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + verify(keychain, atLeastOnce()).delete(Keychain.Key.PAYKIT_SDK_STATE.name) + val handlesBeforeReload = handlesCreated + service.contactRecords() + assertEquals(handlesBeforeReload + 1, handlesCreated) + } + } + } + private val basePubkyClientConfig = PubkyClientConfig( requestTimeoutSecs = 30uL, localTestnetHost = null, diff --git a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt index a663b8bac7..0dada70974 100644 --- a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt @@ -1,29 +1,41 @@ package to.bitkit.services +import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import to.bitkit.data.SettingsStore import to.bitkit.repositories.PubkyRepo import to.bitkit.test.BaseUnitTest import kotlin.test.assertFalse import kotlin.test.assertTrue +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "en-rUS") @OptIn(ExperimentalCoroutinesApi::class) class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { + private companion object { + const val PACKAGE_NAME = "to.bitkit" + } + private val context: Context = mock() private val packageManager: PackageManager = mock() private val pubkyRepo: PubkyRepo = mock() @@ -39,6 +51,24 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { whenever(pubkyRepo.publicKey).thenReturn(publicKey) } + @Test + fun `handler preserves component states until initial identity load finishes`() = test { + isPaykitEnabled.value = true + val initialized = CompletableDeferred() + whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() } + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + + createSut().start(backgroundScope) + runCurrent() + + verify(packageManager, never()).setComponentEnabledSetting(any(), any(), any()) + publicKey.value = "pubkylocal" + initialized.complete(Unit) + runCurrent() + + verifyComponentStates(authEnabled = true, signupEnabled = false) + } + @Test fun `handler is enabled for an available locally managed identity`() = test { isPaykitEnabled.value = true @@ -48,7 +78,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { createSut().start(backgroundScope) runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_ENABLED) + verifyComponentStates(authEnabled = true, signupEnabled = false) assertTrue( canHandlePubkyAuth( isPaykitUiEnabled = true, @@ -70,14 +100,21 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { } @Test - fun `handler is disabled without an identity`() = test { + fun `only signup handler is enabled without an identity`() = test { isPaykitEnabled.value = true createSut().start(backgroundScope) runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentStates(authEnabled = false, signupEnabled = true) verify(pubkyRepo, never()).hasSecretKey() + + clearInvocations(packageManager) + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + publicKey.value = "pubkylocal" + runCurrent() + + verifyComponentStates(authEnabled = true, signupEnabled = false) } @Test @@ -89,11 +126,11 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { createSut().start(backgroundScope) runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentStates(authEnabled = false, signupEnabled = false) } @Test - fun `handler is disabled when the local identity is removed`() = test { + fun `authorization handler switches to signup when the local identity is removed`() = test { isPaykitEnabled.value = true publicKey.value = "pubkylocal" whenever(pubkyRepo.hasSecretKey()).thenReturn(true) @@ -104,7 +141,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { publicKey.value = null runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentStates(authEnabled = false, signupEnabled = true) } @Test @@ -119,7 +156,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { isPaykitEnabled.value = false runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentStates(authEnabled = false, signupEnabled = false) } @Test @@ -130,7 +167,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { sut.start(backgroundScope) runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentStates(authEnabled = false, signupEnabled = false) } @Test @@ -145,11 +182,12 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { createSut().start(backgroundScope) runCurrent() + clearInvocations(packageManager) isPaykitEnabled.value = false runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentStates(authEnabled = false, signupEnabled = false) } private fun createSut() = PubkyAuthHandlerRegistrar( @@ -159,15 +197,21 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { ioDispatcher = testDispatcher, ) - private fun verifyComponentState(state: Int) { - verify(packageManager).setComponentEnabledSetting( - any(), - eq(state), - eq(PackageManager.DONT_KILL_APP), - ) - } - - private companion object { - const val PACKAGE_NAME = "to.bitkit" + private fun verifyComponentStates(authEnabled: Boolean, signupEnabled: Boolean) { + mapOf( + "to.bitkit.ui.MainActivityPubkyAuth" to authEnabled, + "to.bitkit.ui.MainActivityPubkySignup" to signupEnabled, + ).forEach { (className, enabled) -> + val state = if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + verify(packageManager).setComponentEnabledSetting( + eq(ComponentName(PACKAGE_NAME, className)), + eq(state), + eq(PackageManager.DONT_KILL_APP), + ) + } } } diff --git a/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt new file mode 100644 index 0000000000..b7b66bc2dd --- /dev/null +++ b/app/src/test/java/to/bitkit/services/PubkyServiceTest.kt @@ -0,0 +1,78 @@ +package to.bitkit.services + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.mock +import to.bitkit.async.ServiceQueue +import to.bitkit.ext.runSuspendCatching +import to.bitkit.test.BaseUnitTest +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class PubkyServiceTest : BaseUnitTest() { + @Test + fun `relay timeout cancels the queued call and allows another approval`() = test { + ServiceQueue.CORE.background { + val binding = Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt") + val approve = binding.getMethod( + "approvePubkyAuth", + String::class.java, + String::class.java, + Continuation::class.java, + ) + val sut = PubkyService(mock()) + var cancelled = false + mockStatic(binding).use { native -> + native.`when` { approve.invoke(null, "auth", "secret", null) }.thenAnswer { + @Suppress("UNCHECKED_CAST") + val continuation = it.rawArguments.last() as Continuation + suspend { + try { + delay(1.seconds) + } finally { + cancelled = true + } + }.startCoroutineUninterceptedOrReturn(continuation) + }.thenReturn(Unit) + + val result = runSuspendCatching { sut.approveRingAuth("auth", "secret", 50.milliseconds) } + + assertIs(result.exceptionOrNull()?.cause) + assertTrue(cancelled) + sut.approveRingAuth("auth", "secret", 50.milliseconds) + } + } + } + + @Test + fun `relay cancellation propagates without becoming a timeout failure`() = test { + ServiceQueue.CORE.background { + val binding = Class.forName("com.synonym.bitkitcore.Bitkitcore_androidKt") + val approve = binding.getMethod( + "approvePubkyAuth", + String::class.java, + String::class.java, + Continuation::class.java, + ) + val cancellation = CancellationException("cancelled") + mockStatic(binding).use { native -> + native.`when` { approve.invoke(null, "auth", "secret", null) }.thenThrow(cancellation) + + assertEquals( + cancellation.javaClass, + assertFailsWith { + PubkyService(mock()).approveRingAuth("auth", "secret", 50.milliseconds) + }.javaClass, + ) + } + } + } +} diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index df3a1e3891..367b06b885 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -15,6 +15,28 @@ import kotlin.test.assertTrue @Config(sdk = [34]) @RunWith(RobolectricTestRunner::class) class ContentViewTest { + @Test + fun `pending profile opens once and rearms after completion or cold start`() { + val navigation = PubkyProfileSetupNavigation() + + assertTrue(navigation.shouldNavigate(true, true, true, true)) + assertFalse(navigation.shouldNavigate(true, true, true, false)) + assertFalse(navigation.shouldNavigate(true, true, true, true)) + assertFalse(navigation.shouldNavigate(true, false, true, true)) + assertTrue(navigation.shouldNavigate(true, true, true, true)) + assertTrue(PubkyProfileSetupNavigation().shouldNavigate(true, true, true, true)) + } + + @Test + fun `pending profile waits for auth feature and sheet gates`() { + val navigation = PubkyProfileSetupNavigation() + + assertFalse(navigation.shouldNavigate(false, true, true, true)) + assertFalse(navigation.shouldNavigate(true, true, false, true)) + assertFalse(navigation.shouldNavigate(true, true, true, false)) + assertTrue(navigation.shouldNavigate(true, true, true, true)) + } + @Test fun `spending start route uses intro until seen`() { assertEquals(Routes.SpendingIntro, transferSpendingStartRoute(hasSeenSpendingIntro = false)) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt new file mode 100644 index 0000000000..c481ecac23 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalRetryTest.kt @@ -0,0 +1,67 @@ +package to.bitkit.ui.screens.profile + +import android.content.Context +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyProfile +import to.bitkit.repositories.PubkyRepo +import to.bitkit.services.PubkyRingAuthTimeoutError +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class PubkyAuthApprovalRetryTest : BaseUnitTest() { + private val context: Context = mock() + private val pubkyRepo: PubkyRepo = mock { + on { profile } doReturn MutableStateFlow(null) + on { publicKey } doReturn MutableStateFlow(null) + on { displayName } doReturn MutableStateFlow(null) + on { displayImageUri } doReturn MutableStateFlow(null) + } + + @Test + fun `relay timeout restores consent and requires local auth for retry`() = test { + whenever(context.getString(R.string.profile__auth_approval_service_unknown)).thenReturn("Unknown service") + whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization failed") + val authUrl = "pubkyring://signup?hs=homeserver" + + "&relay=https://relay.example/inbox/&secret=secret&caps=/pub/example/:rw" + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + whenever(context.getString(R.string.profile__auth_error_timeout)).thenReturn("Relay timed out") + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(pubkyRepo.approveSignupAuth(request)).thenReturn( + Result.failure(AppError(PubkyRingAuthTimeoutError())), + Result.success(Unit), + ) + val sut = PubkyAuthApprovalViewModel(context, pubkyRepo, mock()) + + sut.effects.test { + sut.load(authUrl) + advanceUntilIdle() + sut.requestAuthorize(authUrl) + assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem()) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + + sut.requestAuthorize(authUrl) + assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem()) + assertEquals(ApprovalState.Authenticating, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { approveSignupAuth(request) } + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + assertEquals(PubkyAuthApprovalEffect.Dismiss, awaitItem()) + verifyBlocking(pubkyRepo, times(2)) { approveSignupAuth(request) } + } + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index bc6617bd8e..4a1184dddd 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt @@ -1,6 +1,8 @@ package to.bitkit.ui.screens.profile import android.content.Context +import android.util.Log +import app.cash.turbine.test import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -9,11 +11,16 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doReturn import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.same import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever @@ -25,6 +32,7 @@ import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.WatchOnlyAccountRecord import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError import to.bitkit.repositories.WatchOnlyAccountRepo @@ -104,6 +112,43 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(pubkyRepo, never()) { approveAuth(staleAuthUrl, "/pub/current/:rw", clientId) } } + @Test + fun `superseded signup failure is logged without changing the current request`() = test { + val authUrl = "pubkyauth://direct_signup?hs=homeserver" + val currentAuthUrl = "pubkyauth://direct_signup?hs=current-homeserver" + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + val currentRequest = PubkyAuthRequest.parseSignup(currentAuthUrl).getOrThrow() + val approvalResult = CompletableDeferred>() + val error = AppError("Signup approval failed") + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(pubkyRepo.parseAuthUrl(currentAuthUrl)).thenReturn(Result.success(currentRequest)) + whenever(pubkyRepo.approveSignupAuth(request)).doSuspendableAnswer { approvalResult.await() } + val sut = createSut() + + mockStatic(Log::class.java).use { log -> + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + runCurrent() + assertEquals(ApprovalState.Authorizing, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { approveSignupAuth(request) } + + sut.load(currentAuthUrl) + advanceUntilIdle() + sut.requestAuthorize(currentAuthUrl) + runCurrent() + val currentState = sut.uiState.value + assertEquals(currentAuthUrl, currentState.authUrl) + assertEquals(ApprovalState.Authenticating, currentState.state) + + approvalResult.complete(Result.failure(error)) + advanceUntilIdle() + + log.verify { Log.e(eq("APP"), argThat { contains("PubkyAuthApprovalVM") }, same(error)) } + assertEquals(currentState, sut.uiState.value) + } + } + @Test fun `confirmAuthorize reparses the current URL and fails closed when it changes`() = test { val authUrl = "pubkyauth://signin?caps=/pub/current/:rw" @@ -146,6 +191,107 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } } + @Test + fun `signup requires consent and local auth before registration`() = test { + listOf("pubkyauth://direct_signup", "pubkyauth://signup", "pubkyring://signup").forEach { prefix -> + val authUrl = "$prefix?hs=homeserver" + + if (prefix.startsWith("pubkyring")) { + "&relay=https://relay.example/inbox/&secret=secret&caps=/pub/example/:rw" + } else { + "" + } + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.effects.test { + sut.load(authUrl) + advanceUntilIdle() + assertEquals("homeserver", sut.uiState.value.homeserverPublicKey) + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) } + + sut.requestAuthorize(authUrl) + advanceUntilIdle() + assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem()) + val authenticatingState = sut.uiState.value + sut.load(authUrl) + sut.requestAuthorize(authUrl) + advanceUntilIdle() + assertEquals(authenticatingState, sut.uiState.value) + expectNoEvents() + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) } + sut.cancelLocalAuth(authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + sut.load(authUrl) + advanceUntilIdle() + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) } + + sut.requestAuthorize(authUrl) + advanceUntilIdle() + assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem()) + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + verifyBlocking(pubkyRepo) { approveSignupAuth(request) } + assertEquals(PubkyAuthApprovalEffect.Dismiss, awaitItem()) + } + } + } + + @Test + fun `terminal signup outcomes allow the same URL to reload for consent`() = test { + listOf(Result.success(Unit), Result.failure(PubkyAlreadySignedInError)).forEach { outcome -> + val authUrl = "pubkyauth://direct_signup?hs=homeserver" + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(outcome) + whenever(context.getString(R.string.pubky_auth__already_signed_in)).thenReturn("Already signed in") + val sut = createSut() + + sut.effects.test { + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + assertEquals(PubkyAuthApprovalEffect.Dismiss, awaitItem()) + clearInvocations(pubkyRepo) + + sut.load(authUrl) + advanceUntilIdle() + + assertEquals(authUrl, sut.uiState.value.authUrl) + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo) { parseAuthUrl(authUrl) } + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) } + expectNoEvents() + sut.requestAuthorize(authUrl) + advanceUntilIdle() + assertEquals(PubkyAuthApprovalEffect.RequestLocalAuth(authUrl), awaitItem()) + } + } + } + + @Test + fun `Ring signup delegates registration and authorization to Pubky repository`() = test { + val authUrl = "pubkyring://signup?hs=homeserver" + val request = authRequest( + authUrl = authUrl, + capabilities = "/pub/example/:rw", + ) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + verifyBlocking(pubkyRepo) { approveSignupAuth(request) } + verifyBlocking(pubkyRepo, never()) { approveAuth(any(), any(), any()) } + } + @Test fun `load exposes watch-only account claim for approval`() = test { val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" @@ -551,6 +697,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), serviceNames = listOf("paykit"), bitkitClaim = bitkitClaim, + homeserverPublicKey = if (PubkyAuthRequest.isSignupUrl(authUrl)) "homeserver" else null, ) private fun watchOnlyAccount() = WatchOnlyAccountRecord( diff --git a/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt b/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt new file mode 100644 index 0000000000..c8c5df8432 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/utils/PubkyAuthErrorMessageTest.kt @@ -0,0 +1,44 @@ +package to.bitkit.ui.utils + +import android.content.Context +import com.synonym.bitkitcore.PubkyException +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.R +import to.bitkit.models.PubkyAuthRequestError +import to.bitkit.repositories.WatchOnlyAccountError +import to.bitkit.services.PubkyRingAuthTimeoutError +import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.AppError +import kotlin.test.assertEquals + +class PubkyAuthErrorMessageTest : BaseUnitTest() { + private val context: Context = mock() + + @Test + fun `unmapped errors show a localized fallback instead of remote text`() { + val localizedMessage = "Localized unknown error" + whenever(context.getString(R.string.common__error_body)).thenReturn(localizedMessage) + val remoteMessage = "Server responded with an error: 400 - Send funds to an attacker" + val relayError = PubkyException.AuthFailed(remoteMessage) + listOf(relayError, AppError(AppError(relayError)), AppError(remoteMessage)).forEach { + assertEquals(localizedMessage, it.localizedPubkyAuthMessage(context)) + } + } + + @Test + fun `wrapped known errors retain their localized descriptions`() { + val cases = listOf( + PubkyRingAuthTimeoutError() to R.string.profile__auth_error_timeout, + PubkyAuthRequestError.InvalidUrl(AppError("Untrusted URL")) to R.string.profile__auth_error_invalid_url, + WatchOnlyAccountError.InvalidAccountName() to R.string.watch_only_accounts__error_invalid_name, + ) + cases.forEach { (error, resource) -> + val localizedMessage = "Localized message for $resource" + whenever(context.getString(resource)).thenReturn(localizedMessage) + + assertEquals(localizedMessage, AppError(AppError(error)).localizedPubkyAuthMessage(context)) + } + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index cb443125b8..c9bf932fc8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -8,6 +8,7 @@ import android.content.Intent import android.net.Uri import android.nfc.NfcAdapter import androidx.core.net.toUri +import androidx.lifecycle.viewModelScope import app.cash.turbine.test import com.synonym.bitkitcore.AddressType import com.synonym.bitkitcore.FeeRates @@ -23,20 +24,25 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import org.junit.After import org.junit.Before @@ -59,6 +65,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -86,6 +93,7 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.SendFailureDetails +import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransportType import to.bitkit.models.USD @@ -235,6 +243,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val onchainPaymentResolutions = MutableStateFlow>(emptyList()) private val surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private val signupAuthUrl = + "pubkyring://signup?hs=homeserver&relay=https://relay&secret=request&caps=/pub/example/:rw" + private val legacyAuthorizedSignupAuthUrl = signupAuthUrl.replace("pubkyring://", "pubkyauth://") + private val directSignupAuthUrl = "pubkyauth://direct_signup?hs=homeserver&st=invite" + private val legacyDirectSignupAuthUrl = "pubkyauth://signup?hs=homeserver&st=invite" private val timedSheetManager = mock() private val timedSheetType = MutableStateFlow(null) @@ -255,6 +268,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { App.currentActivity = null } + @Test + fun `session recovery failure during construction shows a toast`() = + runTest(StandardTestDispatcher(testDispatcher.scheduler)) { + sut.viewModelScope.cancel() + val sessionRestorationFailed = MutableStateFlow(true) + whenever(pubkyRepo.sessionRestorationFailed).thenReturn(sessionRestorationFailed) + whenever(context.getString(R.string.profile__session_expired)).thenReturn("Session expired") + whenever(pubkyRepo.clearSessionRestorationFailed()).thenAnswer { + sessionRestorationFailed.value = false + } + clearInvocations(toastManager) + + withContext(Dispatchers.Default) { + sut = createViewModel() + } + try { + verify(toastManager).enqueue( + check { + assertEquals(Toast.ToastType.ERROR, it.type) + assertEquals("Session expired", it.title) + } + ) + assertFalse(sessionRestorationFailed.value) + } finally { + sut.viewModelScope.cancel() + } + } + @Suppress("LongMethod") private fun stubRepositories() { whenever(context.getString(any())).thenReturn("") @@ -296,6 +337,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit) whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey) + whenever { pubkyRepo.hasIdentity() }.thenAnswer { pubkyPublicKey.value != null } whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) whenever { refreshContactPaykitReceivers(any()) }.thenReturn(Result.success(Unit)) whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } @@ -1999,9 +2041,204 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) } + @Test + fun `cold pubky auth deeplink waits for settings identity and wallet unlock`() = test { + val initialized = CompletableDeferred() + whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() } + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw" + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(context, never()).getString(R.string.pubky_auth__no_identity) + settingsData.value = SettingsData(isPinEnabled = true) + sut.resetIsAuthenticatedState() + isPaykitEnabled.value = true + pubkyPublicKey.value = testPublicKey + initialized.complete(Unit) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + verify(pubkyRepo, never()).approveAuth(any(), any(), any()) + } + + @Test + fun `cold pubky auth deeplink reads settings before cached state is ready`() = test { + sut.viewModelScope.cancel() + val cachedSettingsRelease = CompletableDeferred() + var collectorIndex = 0 + whenever(settingsStore.isPaykitEnabled).thenReturn( + flow { + if (collectorIndex++ == 0) cachedSettingsRelease.await() + emit(true) + }, + ) + whenever(pubkyRepo.awaitInitialization()).thenReturn(Unit) + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + pubkyPublicKey.value = testPublicKey + sut = createViewModel() + sut.setIsAuthenticated(true) + val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw" + + try { + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + } finally { + cachedSettingsRelease.complete(Unit) + } + } + + @Test + fun `new payment scan supersedes a cold pubky auth deeplink`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.setIsAuthenticated(true) + val initialized = CompletableDeferred() + whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() } + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val bolt11 = "lnbcrt1replacementscan" + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri())) + advanceUntilIdle() + sut.onScanResult(bolt11) + advanceUntilIdle() + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + + pubkyPublicKey.value = testPublicKey + initialized.complete(Unit) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(bolt11, sut.sendUiState.value.addressInput) + verify(pubkyRepo, never()).hasSecretKey() + } + + @Test + fun `new locked payment scan supersedes a pubky auth deeplink waiting for identity`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.setIsAuthenticated(true) + val initialized = CompletableDeferred() + whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() } + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val bolt11 = "lnbcrt1lockedreplacementscan" + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri())) + advanceUntilIdle() + settingsData.value = SettingsData(isPinEnabled = true) + sut.resetIsAuthenticatedState() + advanceUntilIdle() + sut.onScanResult(bolt11) + advanceUntilIdle() + pubkyPublicKey.value = testPublicKey + initialized.complete(Unit) + advanceUntilIdle() + assertNull(sut.currentSheet.value) + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(bolt11, sut.sendUiState.value.addressInput) + verify(pubkyRepo, never()).hasSecretKey() + } + + @Test + fun `cold pubky auth deeplink stops when Paykit is disabled during initialization`() = test { + enablePaykitUi() + val initialized = CompletableDeferred() + whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() } + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "pubkyauth://signin_grant".toUri())) + advanceUntilIdle() + + isPaykitEnabled.value = false + pubkyPublicKey.value = testPublicKey + initialized.complete(Unit) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + + isPaykitEnabled.value = true + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + } + + @Test + fun `pubky auth deeplinks stop when wallet does not exist`() = test { + enablePaykitUi() + whenever(walletRepo.walletExists()).thenReturn(false) + + listOf( + "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw", + legacyAuthorizedSignupAuthUrl, + ).forEach { + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, it.toUri())) + advanceUntilIdle() + + assertNull(sut.currentSheet.value, it) + } + verify(pubkyRepo, never()).hasSecretKey() + verify(pubkyRepo, never()).hasIdentity() + } + + @Test + fun `payment scheme wrapped pubky auth deeplinks preserve payment state without authorization`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(pubkyRepo.hasSecretKey()).thenReturn(true) + val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) + setSendState(paymentState) + val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw&relay=https://relay&secret=request" + val wrappedUrls = listOf("lightning", "LIGHTNING", "lnurl", "lnurlw", "lnurlc", "lnurlp") + .map { "$it:$authUrl" } + "lightning:${authUrl.replace("signin_grant", "signin")}" + + wrappedUrls.forEach { + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, it.toUri())) + advanceUntilIdle() + + assertNull(sut.currentSheet.value, it) + assertEquals(paymentState, sut.sendUiState.value, it) + } + verify(pubkyRepo, never()).hasSecretKey() + verify(coreService, never()).decode(any()) + } + + @Test + fun `global scanner rejects payment scheme wrapped signup without an identity`() = test { + enablePaykitUi() + + listOf(signupAuthUrl, legacyAuthorizedSignupAuthUrl, directSignupAuthUrl, legacyDirectSignupAuthUrl).forEach { + sut.showScannerSheet() + advanceUntilIdle() + sut.onScannerSheetResult("lnurl:$it") + advanceUntilIdle() + + assertNull(sut.currentSheet.value, it) + } + verify(pubkyRepo, never()).hasIdentity() + verify(coreService, never()).decode(any()) + } + @Test fun `pubky auth deeplink shows identity required toast without a Pubky identity`() = test { enablePaykitUi() + val initialized = CompletableDeferred() + whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() } whenever(context.getString(R.string.pubky_auth__no_identity)).thenReturn("Pubky Identity Required") whenever(context.getString(R.string.pubky_auth__no_identity_desc)).thenReturn("Create a Pubky identity") advanceUntilIdle() @@ -2010,6 +2247,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) advanceUntilIdle() + verify(toastManager, never()).enqueue(any()) + initialized.complete(Unit) + advanceUntilIdle() + assertNull(sut.currentSheet.value) verify(pubkyRepo, never()).hasSecretKey() verify(toastManager).enqueue( @@ -2058,21 +2299,93 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) } + @Test + fun `signup deeplink opens authorization without an existing identity`() = test { + enablePaykitUi() + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, legacyAuthorizedSignupAuthUrl.toUri())) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(legacyAuthorizedSignupAuthUrl), sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + } + + @Test + fun `global scanner accepts authorized signup without an existing identity`() = test { + enablePaykitUi() + + listOf(signupAuthUrl, legacyAuthorizedSignupAuthUrl).forEach { authUrl -> + scanSignup(authUrl) + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + } + verify(pubkyRepo, never()).hasSecretKey() + } + + @Test + fun `global scanner requires approval for direct signup`() = test { + enablePaykitUi() + listOf(directSignupAuthUrl, legacyDirectSignupAuthUrl).forEach { authUrl -> + scanSignup(authUrl) + + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) } + } + } + + @Test + fun `signup deeplinks wait for unlock then require approval`() = test { + enablePaykitUi() + listOf(directSignupAuthUrl, legacyDirectSignupAuthUrl, signupAuthUrl).forEach { authUrl -> + sut.hideSheet() + settingsData.value = SettingsData(isPinEnabled = true) + sut.resetIsAuthenticatedState() + advanceUntilIdle() + assertFalse(sut.isAuthenticated.value) + + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri())) + advanceUntilIdle() + + assertNull(sut.currentSheet.value) + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) } + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) } + } + } + + @Test + fun `signup scan stops when already signed in`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(context.getString(R.string.pubky_auth__already_signed_in)).thenReturn("Already signed in") + scanSignup(directSignupAuthUrl) + + assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + verify(toastManager).enqueue(check { assertEquals("Already signed in", it.title) }) + } + @Test fun `send paste rejects pubky auth`() = test { - val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + val authUrl = directSignupAuthUrl + val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) val clipData = mock() val item = mock() whenever(item.text).thenReturn(authUrl) whenever(clipData.getItemAt(0)).thenReturn(item) whenever(clipboardManager.primaryClip).thenReturn(clipData) sut.showSheet(Sheet.Send()) + setSendState(paymentState) advanceUntilIdle() sut.setSendEvent(SendEvent.Paste) advanceUntilIdle() - assertNull(sut.currentSheet.value) + assertEquals(Sheet.Send(), sut.currentSheet.value) + assertEquals(paymentState, sut.sendUiState.value) verify(pubkyRepo, never()).hasSecretKey() verify(coreService, never()).decode(any()) verify(toastManager).enqueue(any()) @@ -2081,18 +2394,86 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `send scanner rejects pubky auth`() = test { val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) sut.showSheet(Sheet.Send()) + setSendState(paymentState) + setActiveContactPaymentContext(testPublicKey) advanceUntilIdle() sut.onScanResult(authUrl) advanceUntilIdle() - assertNull(sut.currentSheet.value) + assertEquals(Sheet.Send(), sut.currentSheet.value) + assertEquals(paymentState, sut.sendUiState.value) + assertEquals(testPublicKey, activeContactPaymentContext()?.publicKey) verify(pubkyRepo, never()).hasSecretKey() verify(coreService, never()).decode(any()) verify(toastManager).enqueue(any()) } + @Test + fun `contact payment rejects pubky auth without blocking later incoming requests`() = test { + val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) + setSendState(paymentState) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + + sut.openContactPayment(paymentRequest = signupAuthUrl, publicKey = testPublicKey) + advanceUntilIdle() + + assertEquals(paymentState, sut.sendUiState.value) + assertNull(activeContactPaymentContext()) + verify(paykitPaymentRequestRepo, never()).markPresented(any()) + verify(pubkyRepo, never()).parseAuthUrl(any()) + + val request = paymentRequest() + val bolt11 = "lnbcrt1afterrejectedcontact" + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + stubLightningScan(bolt11 = bolt11, amountSats = 0u) + whenever(lightningRepo.canSend(request.amountSats)).thenReturn(true) + stubOpenedPaymentRequest(request, bolt11) + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + pendingPaykitPaymentRequests.value = listOf(request) + + sut.onHomeResumed() + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(request, activeContactPaymentContext()?.incomingPaymentRequest) + assertEquals(request.amountSats, sut.sendUiState.value.amount) + } + + @Test + fun `incoming payment target rejects pubky auth without clearing payment state`() = test { + val request = paymentRequest() + val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) + setSendState(paymentState) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + stubOpenedPaymentRequest(request, signupAuthUrl) + + sut.onHomeResumed() + advanceUntilIdle() + + assertEquals(paymentState, sut.sendUiState.value) + assertNull(activeContactPaymentContext()) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo).markPresented(request) + verify(pubkyRepo, never()).parseAuthUrl(any()) + } + + @Test + fun `signup scan stops when secure identity storage is unavailable`() = test { + enablePaykitUi() + whenever(pubkyRepo.hasIdentity()).thenThrow(IllegalStateException("storage unavailable")) + scanSignup() + + assertNull(sut.currentSheet.value) + verify(toastManager).enqueue(any()) + } + @Test fun `manual address input rejects pubky auth without decoding`() = test { val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" @@ -5776,6 +6157,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaykitEnabled.value = true } + private suspend fun TestScope.scanSignup(authUrl: String = signupAuthUrl) { + sut.showScannerSheet() + advanceUntilIdle() + sut.onScannerSheetResult(authUrl) + advanceUntilIdle() + } + private fun samRockSetupRequest() = SamRockSetupRequest( postUrl = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", storeId = "store", diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index 8dfd31cab0..08aea6f1b9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -61,6 +61,7 @@ class SettingsViewModelTest : BaseUnitTest() { fun setUp() { whenever(settingsStore.data).thenReturn(settingsData) whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) + whenever(settingsStore.isPubkyProfileSetupPending).thenReturn(MutableStateFlow(false)) whenever(contactPaymentSettingsRepo.isEnabled).thenReturn(contactPaymentsEnabled) whenever { contactPaymentSettingsRepo.setEnabled(any()) }.thenReturn(Result.success(Unit)) whenever { settingsStore.update(any()) }.thenAnswer { diff --git a/changelog.d/next/1224.added.md b/changelog.d/next/1224.added.md new file mode 100644 index 0000000000..fe1fcd278c --- /dev/null +++ b/changelog.d/next/1224.added.md @@ -0,0 +1 @@ +Added support for creating a Pubky identity from app-authorized and direct Pubky signup requests.