diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c20fd33b3b..5132e9089b 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 eddec111d1..833e2d156c 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 @@ -66,6 +69,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() @@ -98,6 +105,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..a6ce704c84 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 } @@ -553,25 +554,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() + 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 } - 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 +601,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 +634,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 +976,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 +1003,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 +1415,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 39bfef26d6..088e0221d9 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 @@ -158,6 +160,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() { @@ -266,6 +282,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() } @@ -865,6 +915,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) @@ -913,13 +972,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..ebcfa03acf 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) @@ -48,17 +57,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 +79,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..ac3a82bf7c 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,14 +1,18 @@ 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 to.bitkit.async.ServiceQueue import to.bitkit.ext.runSuspendCatching import to.bitkit.utils.AppError import javax.inject.Inject import javax.inject.Singleton +import com.synonym.bitkitcore.parsePubkyAuthUrl as parseLegacyPubkyAuthUrl @Suppress("TooManyFunctions") @Singleton @@ -76,6 +80,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 +123,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 +139,10 @@ class PubkyService @Inject constructor( paykitSdkService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex) } + suspend fun approveRingAuth(authUrl: String, secretKeyHex: String) = ServiceQueue.CORE.background { + approvePubkyAuth(authUrl, secretKeyHex) + } + suspend fun approveAuthWithCompanionClaim( authUrl: String, expectedCapabilities: String, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 3ebba1bb58..2c7996ea25 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -449,6 +449,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() @@ -626,6 +627,7 @@ fun ContentView( ) { Box(modifier = Modifier.fillMaxSize()) { var isHomeCalculatorInputActive by remember { mutableStateOf(false) } + val pubkyProfileSetupNavigation = remember { PubkyProfileSetupNavigation() } RootNavHost( navController = navController, @@ -646,6 +648,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() @@ -705,6 +726,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, @@ -1502,7 +1543,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 8cb85ac8a7..8a427bfbef 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 @@ -380,23 +380,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..56765eb343 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) { @@ -273,8 +294,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 +328,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/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 91dfb48792..6e072a709a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -114,6 +114,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 @@ -130,6 +131,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 @@ -182,6 +184,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 @@ -1709,7 +1712,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) { @@ -1921,21 +1924,15 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean = false, contactPaymentContext: ContactPaymentContext? = null, preserveUntilComplete: Boolean = false, + allowPubkyAuth: Boolean = isMainScanner, ) { if (!_isAuthenticated.value) { - enqueueDeferredScan( - source = source, - data = data, - startDelay = startDelay, - routePubkyKeys = routePubkyKeys, - contactPaymentContext = contactPaymentContext, - ) + enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth) return } val normalized = data.removeLightningSchemes() val scanId = scanLogId(data) - val scheduled = scheduledScan val isSameActiveScan = normalized == scheduled?.normalizedInput && scheduled.job.isActive && @@ -1946,16 +1943,16 @@ 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 } val previousJob = scheduled?.job val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { scanMutex.withLock { - setActiveContactPaymentContext(contactPaymentContext) + prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext) if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys) + handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth) } } val nextScheduledScan = ScheduledScan( @@ -1981,7 +1978,7 @@ class AppViewModel @Inject constructor( } 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 { @@ -1995,6 +1992,7 @@ class AppViewModel @Inject constructor( startDelay: Duration, routePubkyKeys: Boolean, contactPaymentContext: ContactPaymentContext?, + allowPubkyAuth: Boolean, ) { val scanId = scanLogId(data) val normalized = data.removeLightningSchemes() @@ -2008,6 +2006,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) return } @@ -2026,6 +2025,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) @@ -2064,6 +2064,7 @@ class AppViewModel @Inject constructor( routePubkyKeys = pending.routePubkyKeys, contactPaymentContext = pending.contactPaymentContext, preserveUntilComplete = true, + allowPubkyAuth = pending.allowPubkyAuth, ) } @@ -2415,6 +2416,7 @@ class AppViewModel @Inject constructor( startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, contactPaymentContext: ContactPaymentContext? = null, + allowPubkyAuth: Boolean = isMainScanner, ) { launchScan( source = ScanSource.SCAN_RESULT, @@ -2422,6 +2424,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) } @@ -2436,7 +2439,11 @@ class AppViewModel @Inject constructor( privatePaymentContext = privatePaymentContext, incomingPaymentRequest = incomingPaymentRequest, ) - onScanResult(paymentRequest, contactPaymentContext = context) + onScanResult( + data = paymentRequest, + contactPaymentContext = context, + allowPubkyAuth = false, + ) } fun preserveContactPaymentContext(paymentHash: String) { @@ -2453,7 +2460,21 @@ class AppViewModel @Inject constructor( private suspend fun handleScan( result: String, routePubkyKeys: Boolean, + contactPaymentContext: ContactPaymentContext?, + allowPubkyAuth: Boolean, ) = withContext(bgDispatcher) { + val input = result.removeLightningSchemes() + + if (PubkyAuthRequest.isProtocolUrl(input) && !allowPubkyAuth) { + 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@withContext + } + val contactPaymentProfile = activeContactPaymentProfile() val isPaymentRequest = activeIncomingPaymentRequest() != null // always reset state on new scan @@ -2461,7 +2482,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)) { @@ -2486,16 +2506,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 (isPaykitEnabled.value) { handlePubkyAuth(input) } else { hideSheet() @@ -2624,12 +2637,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 } @@ -2642,6 +2650,25 @@ class AppViewModel @Inject constructor( isSubmittingPaymentRequest = false } + 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 @@ -2649,6 +2676,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) @@ -4629,9 +4665,14 @@ class AppViewModel @Inject constructor( return@launch } - if (uri.scheme == PUBKYAUTH_SCHEME) { - if (!isPaykitEnabled.value) return@launch - handlePubkyAuth(uri.toString()) + if (PubkyAuthRequest.isProtocolUrl(uri.toString())) { + if (!isPaykitEnabled.value || !walletRepo.walletExists()) return@launch + launchScan( + source = ScanSource.DEEPLINK, + data = uri.toString(), + startDelay = SCREEN_TRANSITION_DELAY, + allowPubkyAuth = true, + ) return@launch } @@ -4653,7 +4694,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), @@ -4662,7 +4706,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), @@ -4672,6 +4716,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 -> { @@ -4756,7 +4818,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. */ @@ -4793,6 +4854,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 ed794ef4ec..07fcfb9a9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -684,8 +684,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..7796c7539f 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,88 @@ 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://?caps=rw", "pubkyauth://signin?caps=rw", "pubkyauth://grant?caps=rw") + val unrelatedUrls = listOf("pubkyring://auth", "pubkyring://direct_signup") + 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, + signupUrls.filter { it.startsWith("pubkyauth:") } + authUrls, + authAlias, + ) + assertRoutes(packageManager, application.packageName, listOf(signupUrls.first()) + 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..287a411b6c 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,7 +43,9 @@ 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 @@ -74,12 +79,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 +116,116 @@ 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 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()) } + 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()) } + 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 +658,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() @@ -1563,6 +1864,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..8f91d4810c 100644 --- a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt @@ -1,5 +1,6 @@ package to.bitkit.services +import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -7,6 +8,7 @@ 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 @@ -16,14 +18,22 @@ 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() @@ -48,7 +58,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 +80,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 +106,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 +121,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { publicKey.value = null runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentStates(authEnabled = false, signupEnabled = true) } @Test @@ -119,7 +136,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { isPaykitEnabled.value = false runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentStates(authEnabled = false, signupEnabled = false) } @Test @@ -130,7 +147,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { sut.start(backgroundScope) runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentStates(authEnabled = false, signupEnabled = false) } @Test @@ -145,11 +162,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 +177,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/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/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index bc6617bd8e..358c0461b1 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,15 @@ 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.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 @@ -104,6 +110,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 +189,65 @@ 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()) + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(request) } + sut.cancelLocalAuth(authUrl) + 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 `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 +653,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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 75eb224dda..20f69313ed 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -56,6 +56,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 @@ -219,6 +220,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val paykitPaymentRequestHistory = 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) @@ -280,6 +286,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()) } @@ -1918,21 +1925,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()) @@ -1941,18 +2020,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" @@ -5016,6 +5163,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.