From 0326e58b0d4fae764c7f9b2bd69b7635e92e59d5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:21:58 -0500 Subject: [PATCH 01/14] feat: support pubky ring signup --- .../main/java/to/bitkit/data/SettingsStore.kt | 8 ++ .../java/to/bitkit/models/PubkyAuthRequest.kt | 84 ++++++++++++ .../java/to/bitkit/repositories/PubkyRepo.kt | 68 +++++++++- .../to/bitkit/services/PaykitSdkService.kt | 15 +++ .../java/to/bitkit/services/PubkyService.kt | 19 +++ app/src/main/java/to/bitkit/ui/ContentView.kt | 19 ++- .../screens/profile/PubkyAuthApprovalSheet.kt | 18 ++- .../profile/PubkyAuthApprovalViewModel.kt | 30 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 125 +++++++++++++----- .../to/bitkit/viewmodels/SettingsViewModel.kt | 3 + app/src/main/res/values/strings.xml | 1 + .../to/bitkit/models/PubkyAuthRequestTest.kt | 37 ++++++ .../to/bitkit/repositories/PubkyRepoTest.kt | 73 ++++++++++ .../profile/PubkyAuthApprovalViewModelTest.kt | 21 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 74 ++++++++++- .../viewmodels/SettingsViewModelTest.kt | 1 + changelog.d/next/1224.added.md | 1 + 17 files changed, 550 insertions(+), 47 deletions(-) create mode 100644 changelog.d/next/1224.added.md 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..b46f3d53a7 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 isRingSignup: Boolean + get() = isRingSignupUrl(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,56 @@ 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 isRingSignupUrl(rawUrl: String): Boolean = runCatching { + val uri = URI(rawUrl) + uri.scheme.equals("pubkyring", ignoreCase = true) && + uri.host.equals("signup", ignoreCase = true) + }.getOrDefault(false) + + fun parseRingSignup(rawUrl: String): Result = runCatching { + val uri = URI(rawUrl) + require( + uri.scheme.equals("pubkyring", ignoreCase = true) && + uri.host.equals("signup", ignoreCase = true), + ) { "Unsupported Pubky signup URL" } + val query = parseQuery(uri) + val relay = query.requiredSingle("relay") + val secret = query.requiredSingle("secret") + val capabilities = query.requiredSingle("caps") + val homeserver = query.requiredSingle("hs") + val authorizationUrl = ringAuthorizationUrl(relay, secret, capabilities) + + parse( + rawUrl = rawUrl, + clientId = "", + relay = relay, + capabilities = capabilities, + homeserverPublicKey = homeserver, + signupToken = query.optionalSingle("st"), + authorizationUrl = authorizationUrl, + ).getOrThrow().also { + require(it.bitkitClaim == null) { "Ring signup does not support Bitkit companion claims" } + } + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) }, + ) + fun parseBitkitClaim(rawUrl: String, capabilities: String): Result = parseBitkitClaimValues(rawUrl).fold( onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) }, @@ -152,5 +210,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..66cdfd7e2f 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,6 +554,16 @@ class PubkyRepo @Inject constructor( tags: List, avatarBytes: ByteArray?, ): Result { + if (settingsStore.isPubkyProfileSetupPending.first()) { + 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 { @@ -570,8 +581,7 @@ class PubkyRepo @Inject constructor( pubkyService.signIn(secretKeyHex) } - 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 +594,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 +627,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 +969,22 @@ class PubkyRepo @Inject constructor( managedSecretKeyFor(publicKey) != null }.getOrDefault(false) + fun hasIdentity(): Boolean = + _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.isRingSignupUrl(authUrl)) { + val request = PubkyAuthRequest.parseRingSignup(authUrl).getOrThrow() + pubkyService.validateRingSignupAuth( + authorizationUrl = request.authorizationUrl, + homeserverPublicKey = requireNotNull(request.homeserverPublicKey), + ) + return@withContext request + } + val details = pubkyService.parseAuthUrl(authUrl) PubkyAuthRequest.parse( rawUrl = authUrl, @@ -958,6 +995,32 @@ class PubkyRepo @Inject constructor( } } + suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = initializeMutex.withLock { + runSuspendCatching { + withContext(ioDispatcher) { + require(request.isRingSignup) { "Not a Pubky Ring signup request" } + if (hasIdentity()) throw PubkyAlreadySignedInError + + val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() + if (hasIdentity()) throw PubkyAlreadySignedInError + + pubkyService.registerIdentity( + secretKeyHex = secretKeyHex, + homeserverZ32 = requireNotNull(request.homeserverPublicKey), + signupCode = request.signupToken, + ) + pubkyService.approveRingAuth(request.authorizationUrl, secretKeyHex) + settingsStore.setPubkyProfileSetupPending(true) + pubkyService.signIn(secretKeyHex) + + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + notifyBackupStateChanged() + } + } + } + suspend fun approveAuth( authUrl: String, expectedCapabilities: String, @@ -1320,6 +1383,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..ce2a118be7 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -266,6 +266,21 @@ class PaykitSdkService @Inject constructor( return result } + suspend fun registerIdentity( + secretKeyHex: String, + homeserverPublicKey: String, + signupCode: String?, + ) { + isSetup.await() + bootstrap().signUp( + localSecretKey = localSecretKey(secretKeyHex), + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), + homeserverPublicKey = homeserverPublicKey, + signupCode = signupCode, + requiredCapabilities = requiredCapabilities(), + ) + } + suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 79bebe0881..c21f6ebf4c 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,14 +1,17 @@ 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 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 +79,11 @@ class PubkyService @Inject constructor( Unit } + suspend fun registerIdentity(secretKeyHex: String, homeserverZ32: String, signupCode: String?) = + ServiceQueue.CORE.background { + paykitSdkService.registerIdentity(secretKeyHex, homeserverZ32, signupCode) + } + suspend fun signIn(secretKeyHex: String): Unit = ServiceQueue.CORE.background { paykitSdkService.signIn(secretKeyHex) Unit @@ -106,6 +114,13 @@ class PubkyService @Inject constructor( PaykitSdkService.parseAuthUrl(url) } + suspend fun validateRingSignupAuth(authorizationUrl: String, homeserverPublicKey: String): Unit = + ServiceQueue.CORE.background { + parseLegacyPubkyAuthUrl(authorizationUrl) + PaykitPublicKeys.normalize(homeserverPublicKey) + Unit + } + suspend fun approveAuth( authUrl: String, expectedCapabilities: String, @@ -115,6 +130,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 46113e75bd..2c0a203cb7 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -448,6 +448,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() @@ -642,6 +643,22 @@ 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 + val shouldResumeProfileSetup = isPaykitEnabled && + isPubkyProfileSetupPending && + isProfileAuthenticated + if (shouldResumeProfileSetup && canNavigate) { + navController.navigateTo(Routes.CreateProfile) + } + } val currentHardwareWalletId = navBackStackEntry ?.takeIf { it.destination.hasRoute() } ?.toRoute() @@ -1498,7 +1515,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..ab8632c00b 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 @@ -382,13 +382,17 @@ private fun ColumnScope.ApprovalDetails( 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.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) FillHeight(min = 32.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..0dcad59906 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 @@ -171,6 +172,10 @@ class PubkyAuthApprovalViewModel @Inject constructor( if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) + if (request.isRingSignup) { + _effects.emit(PubkyAuthApprovalEffect.Dismiss) + return + } _uiState.update { state -> if (state.authUrl == authUrl) state.copy(state = ApprovalState.Success) else state } @@ -179,6 +184,21 @@ class PubkyAuthApprovalViewModel @Inject constructor( private suspend fun approveRequest( request: PubkyAuthRequest, authUrl: String, + ): Boolean = if (request.isRingSignup) { + 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 +293,16 @@ class PubkyAuthApprovalViewModel @Inject constructor( } private suspend fun handleApprovalFailure(error: Throwable, authUrl: String) { - 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 + } + Logger.error("Auth approval failed", error, context = TAG) _uiState.update { it.copy(state = ApprovalState.Authorize) } ToastEventBus.send( type = Toast.ToastType.ERROR, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 91dfb48792..6e8379659c 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?) { + val request = context?.incomingPaymentRequest ?: return + synchronized(contactPaymentContextLock) { + if (activeContactPaymentContext != context) return + activeContactPaymentContext = null + preparedContactPaymentContext = null + } + markIncomingPaymentRequestPresented(request) + } + + 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,7 +4665,7 @@ class AppViewModel @Inject constructor( return@launch } - if (uri.scheme == PUBKYAUTH_SCHEME) { + if (PubkyAuthRequest.isProtocolUrl(uri.toString())) { if (!isPaykitEnabled.value) return@launch handlePubkyAuth(uri.toString()) return@launch @@ -4653,7 +4689,10 @@ class AppViewModel @Inject constructor( } private suspend fun handlePubkyAuth(authUrl: String) { - if (pubkyRepo.publicKey.value == null) { + val isRingSignup = PubkyAuthRequest.isRingSignupUrl(authUrl) + if (isRingSignup && rejectPubkySignupForExistingIdentity()) return + + if (!isRingSignup && pubkyRepo.publicKey.value == null) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.pubky_auth__no_identity), @@ -4662,7 +4701,7 @@ class AppViewModel @Inject constructor( return } - if (!pubkyRepo.hasSecretKey()) { + if (!isRingSignup && !pubkyRepo.hasSecretKey()) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.profile__auth_approval_ring_only), @@ -4672,6 +4711,24 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } + private suspend fun rejectPubkySignupForExistingIdentity(): Boolean { + val hasIdentity = runCatching { 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 +4813,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 +4849,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 10f3e82627..40b4913792 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -682,6 +682,7 @@ Suggestions To Add Your Name Your Pubky + Already signed in Pubky Identity Required Create a Pubky identity in your profile to approve auth requests. Back Up diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 1577dd834e..9c4647b414 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -1,13 +1,43 @@ 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 Ring signup preserves registration and authorization details`() { + val request = PubkyAuthRequest.parseRingSignup(ringSignupUrl("invite code")).getOrThrow() + + assertTrue(request.isRingSignup) + 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 Ring signup rejects missing and duplicate required values`() { + val invalidUrls = listOf( + ringSignupUrl().replace("&secret=secret", ""), + "${ringSignupUrl()}&hs=other", + ) + + invalidUrls.forEach { url -> + assertIs(PubkyAuthRequest.parseRingSignup(url).exceptionOrNull()) + } + } + @Test fun `parse recognizes watch-only account claim`() { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES @@ -50,6 +80,7 @@ class PubkyAuthRequestTest { capabilities = "/pub/bitkit.to/:rw", ).getOrThrow() + assertFalse(request.isRingSignup) assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } @@ -261,4 +292,10 @@ class PubkyAuthRequestTest { } return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims" } + + private fun ringSignupUrl(signupToken: String? = null): String = + "pubkyring://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() } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 444a811df9..3502c6cea6 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -41,6 +41,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain 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 +75,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 +112,61 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isAuthenticated.value) } + @Test + fun `Ring signup registers and authorizes before activating the local session`() = test { + val events = mutableListOf() + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenAnswer { + events += "register" + } + whenever(pubkyService.approveRingAuth(request.authorizationUrl, "secret")).thenAnswer { + events += "authorize" + } + whenever(pubkyService.signIn("secret")).thenAnswer { events += "activate" } + + val result = sut.approveSignupAuth(request) + + assertTrue(result.isSuccess) + assertEquals(listOf("register", "authorize", "activate"), events) + assertTrue(profileSetupPending.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + + @Test + fun `Ring signup marks profile setup pending before local activation`() = test { + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.signIn("secret")).thenAnswer { + assertTrue(profileSetupPending.value) + throw TestAppError("activation failed") + } + + assertTrue(sut.approveSignupAuth(request).isFailure) + assertTrue(profileSetupPending.value) + assertNull(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()) { signIn(any()) } + 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(runCatching { sut.hasIdentity() }.isFailure) + } + @Test fun `startAuthentication should return auth uri on success`() = test { val authUri = "pubky://auth?capabilities=..." @@ -1563,6 +1625,17 @@ 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.parseRingSignup( + "pubkyring://signup?hs=homeserver&relay=https%3A%2F%2Frelay.example" + + "&secret=request&caps=%2Fpub%2Fexample%2F%3Arw&st=invite", + ).getOrThrow() + private fun createPaykitProfile( name: String, bio: String = "", 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..af85469b60 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 @@ -146,6 +146,26 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } } + @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 +571,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), serviceNames = listOf("paykit"), bitkitClaim = bitkitClaim, + homeserverPublicKey = if (PubkyAuthRequest.isRingSignupUrl(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..f56e9c5e3b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -219,6 +219,8 @@ 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 timedSheetManager = mock() private val timedSheetType = MutableStateFlow(null) @@ -280,6 +282,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 +1921,45 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) } + @Test + fun `global scanner accepts Ring signup without an existing identity`() = test { + enablePaykitUi() + scanSignup() + + assertEquals(Sheet.PubkyAuth(signupAuthUrl), sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + } + + @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() + + 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 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 +1968,54 @@ 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 `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 +5079,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaykitEnabled.value = true } + private suspend fun TestScope.scanSignup() { + sut.showScannerSheet() + advanceUntilIdle() + sut.onScannerSheetResult(signupAuthUrl) + 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..8aab2c4bb0 --- /dev/null +++ b/changelog.d/next/1224.added.md @@ -0,0 +1 @@ +Added support for creating a Pubky identity from Pubky Ring signup requests. From 238d78ce349ad97b759506ec2e35492d499e175f Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:35:04 -0500 Subject: [PATCH 02/14] fix: complete pubky ring signup --- .../java/to/bitkit/repositories/PubkyRepo.kt | 4 +-- .../to/bitkit/services/PaykitSdkService.kt | 17 +++++++++-- .../java/to/bitkit/services/PubkyService.kt | 11 ++++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 7 ++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 29 ++++++++++++++++--- 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 66cdfd7e2f..3adc9c2cf0 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1004,14 +1004,14 @@ class PubkyRepo @Inject constructor( val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() if (hasIdentity()) throw PubkyAlreadySignedInError - pubkyService.registerIdentity( + val registeredSession = pubkyService.registerIdentity( secretKeyHex = secretKeyHex, homeserverZ32 = requireNotNull(request.homeserverPublicKey), signupCode = request.signupToken, ) pubkyService.approveRingAuth(request.authorizationUrl, secretKeyHex) settingsStore.setPubkyProfileSetupPending(true) - pubkyService.signIn(secretKeyHex) + pubkyService.activateRegisteredIdentity(registeredSession) settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } _publicKey.update { publicKey } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index ce2a118be7..ea864d842d 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -270,9 +270,9 @@ class PaykitSdkService @Inject constructor( secretKeyHex: String, homeserverPublicKey: String, signupCode: String?, - ) { + ): PubkySessionBootstrapResult { isSetup.await() - bootstrap().signUp( + return bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, @@ -281,6 +281,19 @@ class PaykitSdkService @Inject constructor( ) } + suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) { + isSetup.await() + val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + operationMutex.withLock { + activateBootstrapResult( + result = result, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = true, + ) + } + notifyBackupStateChanged() + } + suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index c21f6ebf4c..1c473df599 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -6,6 +6,7 @@ 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 @@ -79,11 +80,19 @@ class PubkyService @Inject constructor( Unit } - suspend fun registerIdentity(secretKeyHex: String, homeserverZ32: String, signupCode: String?) = + 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 diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2c0a203cb7..48adf172d4 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -623,6 +623,7 @@ fun ContentView( ) { Box(modifier = Modifier.fillMaxSize()) { var isHomeCalculatorInputActive by remember { mutableStateOf(false) } + var didResumePendingPubkyProfileSetup by remember { mutableStateOf(false) } RootNavHost( navController = navController, @@ -650,12 +651,16 @@ fun ContentView( currentSheet, currentRoute, ) { + if (!isPubkyProfileSetupPending) { + didResumePendingPubkyProfileSetup = false + } val canNavigate = currentSheet == null && currentRoute != Routes.CreateProfile::class.qualifiedName val shouldResumeProfileSetup = isPaykitEnabled && isPubkyProfileSetupPending && isProfileAuthenticated - if (shouldResumeProfileSetup && canNavigate) { + if (shouldResumeProfileSetup && canNavigate && !didResumePendingPubkyProfileSetup) { + didResumePendingPubkyProfileSetup = true navController.navigateTo(Routes.CreateProfile) } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 3502c6cea6..c721f361ec 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 @@ -115,29 +116,49 @@ class PubkyRepoTest : BaseUnitTest() { @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(request.authorizationUrl, "secret")).thenAnswer { events += "authorize" } - whenever(pubkyService.signIn("secret")).thenAnswer { events += "activate" } + 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 marks profile setup pending before local activation`() = test { + fun `Ring signup does not activate the registered session when authorization fails`() = test { + val registeredSession = mock() val request = ringSignupRequest() stubSignupKeys() - whenever(pubkyService.signIn("secret")).thenAnswer { + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + whenever(pubkyService.approveRingAuth(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 marks profile setup pending before activating the registered session`() = test { + val registeredSession = mock() + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { assertTrue(profileSetupPending.value) throw TestAppError("activation failed") } @@ -156,7 +177,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.approveSignupAuth(request).isFailure) verifyBlocking(pubkyService, never()) { approveRingAuth(any(), any()) } - verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) } assertFalse(profileSetupPending.value) } From 305e75e9e421886bf7577cd4de21f5ca38ceeef1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:56:20 -0500 Subject: [PATCH 03/14] feat: support direct pubky signup --- .../java/to/bitkit/models/PubkyAuthRequest.kt | 50 ++++++++++++------- .../java/to/bitkit/repositories/PubkyRepo.kt | 10 ++-- .../java/to/bitkit/services/PubkyService.kt | 4 +- .../profile/PubkyAuthApprovalViewModel.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 36 +++++++++++-- .../to/bitkit/models/PubkyAuthRequestTest.kt | 26 ++++++++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 24 +++++++-- .../profile/PubkyAuthApprovalViewModelTest.kt | 2 +- .../viewmodels/AppViewModelSendFlowTest.kt | 30 +++++++++-- changelog.d/next/1224.added.md | 2 +- 10 files changed, 142 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index b46f3d53a7..b713cb80ec 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -74,10 +74,10 @@ data class PubkyAuthRequest( val bitkitClaim: PubkyAuthClaim?, val homeserverPublicKey: String? = null, val signupToken: String? = null, - val authorizationUrl: String = rawUrl, + val authorizationUrl: String? = rawUrl, ) { - val isRingSignup: Boolean - get() = isRingSignupUrl(rawUrl) + val isSignup: Boolean + get() = isSignupUrl(rawUrl) companion object { @Suppress("LongParameterList") @@ -88,7 +88,7 @@ data class PubkyAuthRequest( capabilities: String, homeserverPublicKey: String? = null, signupToken: String? = null, - authorizationUrl: String = rawUrl, + authorizationUrl: String? = rawUrl, ): Result = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim -> val permissions = parseCapabilities(capabilities) PubkyAuthRequest( @@ -114,24 +114,25 @@ data class PubkyAuthRequest( } }.getOrDefault(false) - fun isRingSignupUrl(rawUrl: String): Boolean = runCatching { - val uri = URI(rawUrl) - uri.scheme.equals("pubkyring", ignoreCase = true) && - uri.host.equals("signup", ignoreCase = true) - }.getOrDefault(false) + fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false) + + fun isDirectSignupUrl(rawUrl: String): Boolean = + runCatching { URI(rawUrl).isDirectSignupRequest() }.getOrDefault(false) - fun parseRingSignup(rawUrl: String): Result = runCatching { + fun parseSignup(rawUrl: String): Result = runCatching { val uri = URI(rawUrl) - require( - uri.scheme.equals("pubkyring", ignoreCase = true) && - uri.host.equals("signup", ignoreCase = true), - ) { "Unsupported Pubky signup URL" } + require(uri.isSignupRequest()) { "Unsupported Pubky signup URL" } val query = parseQuery(uri) - val relay = query.requiredSingle("relay") - val secret = query.requiredSingle("secret") - val capabilities = query.requiredSingle("caps") val homeserver = query.requiredSingle("hs") - val authorizationUrl = ringAuthorizationUrl(relay, secret, capabilities) + val authorizesApp = uri.scheme.equals("pubkyring", ignoreCase = true) + 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, @@ -142,13 +143,24 @@ data class PubkyAuthRequest( signupToken = query.optionalSingle("st"), authorizationUrl = authorizationUrl, ).getOrThrow().also { - require(it.bitkitClaim == null) { "Ring signup does not support Bitkit companion claims" } + 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) + } + fun parseBitkitClaim(rawUrl: String, capabilities: String): Result = parseBitkitClaimValues(rawUrl).fold( onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) }, diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 3adc9c2cf0..3e98aba90c 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -976,9 +976,9 @@ class PubkyRepo @Inject constructor( suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { withContext(ioDispatcher) { - if (PubkyAuthRequest.isRingSignupUrl(authUrl)) { - val request = PubkyAuthRequest.parseRingSignup(authUrl).getOrThrow() - pubkyService.validateRingSignupAuth( + if (PubkyAuthRequest.isSignupUrl(authUrl)) { + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + pubkyService.validateSignupRequest( authorizationUrl = request.authorizationUrl, homeserverPublicKey = requireNotNull(request.homeserverPublicKey), ) @@ -998,7 +998,7 @@ class PubkyRepo @Inject constructor( suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = initializeMutex.withLock { runSuspendCatching { withContext(ioDispatcher) { - require(request.isRingSignup) { "Not a Pubky Ring signup request" } + require(request.isSignup) { "Not a Pubky signup request" } if (hasIdentity()) throw PubkyAlreadySignedInError val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() @@ -1009,7 +1009,7 @@ class PubkyRepo @Inject constructor( homeserverZ32 = requireNotNull(request.homeserverPublicKey), signupCode = request.signupToken, ) - pubkyService.approveRingAuth(request.authorizationUrl, secretKeyHex) + request.authorizationUrl?.let { pubkyService.approveRingAuth(it, secretKeyHex) } settingsStore.setPubkyProfileSetupPending(true) pubkyService.activateRegisteredIdentity(registeredSession) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 1c473df599..ac3a82bf7c 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -123,9 +123,9 @@ class PubkyService @Inject constructor( PaykitSdkService.parseAuthUrl(url) } - suspend fun validateRingSignupAuth(authorizationUrl: String, homeserverPublicKey: String): Unit = + suspend fun validateSignupRequest(authorizationUrl: String?, homeserverPublicKey: String): Unit = ServiceQueue.CORE.background { - parseLegacyPubkyAuthUrl(authorizationUrl) + authorizationUrl?.let { parseLegacyPubkyAuthUrl(it) } PaykitPublicKeys.normalize(homeserverPublicKey) Unit } 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 0dcad59906..e95a4a704f 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 @@ -172,7 +172,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) - if (request.isRingSignup) { + if (request.isSignup) { _effects.emit(PubkyAuthApprovalEffect.Dismiss) return } @@ -184,7 +184,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( private suspend fun approveRequest( request: PubkyAuthRequest, authUrl: String, - ): Boolean = if (request.isRingSignup) { + ): Boolean = if (request.isSignup) { pubkyRepo.approveSignupAuth(request).fold( onSuccess = { true }, onFailure = { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6e8379659c..7f585e57bd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -163,6 +163,7 @@ import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo @@ -4689,10 +4690,15 @@ class AppViewModel @Inject constructor( } private suspend fun handlePubkyAuth(authUrl: String) { - val isRingSignup = PubkyAuthRequest.isRingSignupUrl(authUrl) - if (isRingSignup && rejectPubkySignupForExistingIdentity()) return + val isSignup = PubkyAuthRequest.isSignupUrl(authUrl) + if (isSignup && rejectPubkySignupForExistingIdentity()) return - if (!isRingSignup && pubkyRepo.publicKey.value == null) { + if (PubkyAuthRequest.isDirectSignupUrl(authUrl)) { + handleDirectPubkySignup(authUrl) + return + } + + if (!isSignup && pubkyRepo.publicKey.value == null) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.pubky_auth__no_identity), @@ -4701,7 +4707,7 @@ class AppViewModel @Inject constructor( return } - if (!isRingSignup && !pubkyRepo.hasSecretKey()) { + if (!isSignup && !pubkyRepo.hasSecretKey()) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.profile__auth_approval_ring_only), @@ -4711,6 +4717,28 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } + private suspend fun handleDirectPubkySignup(authUrl: String) { + hideSheet() + val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__auth_error_title), + description = it.localizedPubkyAuthMessage(context), + ) + return + } + pubkyRepo.approveSignupAuth(request).onFailure { + val alreadySignedIn = it is PubkyAlreadySignedInError + ToastEventBus.send( + type = if (alreadySignedIn) Toast.ToastType.INFO else Toast.ToastType.ERROR, + title = context.getString( + if (alreadySignedIn) R.string.pubky_auth__already_signed_in else R.string.profile__auth_error_title, + ), + description = if (alreadySignedIn) null else it.localizedPubkyAuthMessage(context), + ) + } + } + private suspend fun rejectPubkySignupForExistingIdentity(): Boolean { val hasIdentity = runCatching { pubkyRepo.hasIdentity() }.getOrElse { ToastEventBus.send( diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 9c4647b414..2db0a79abc 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -12,9 +12,9 @@ class PubkyAuthRequestTest { @Test fun `parse Ring signup preserves registration and authorization details`() { - val request = PubkyAuthRequest.parseRingSignup(ringSignupUrl("invite code")).getOrThrow() + val request = PubkyAuthRequest.parseSignup(ringSignupUrl("invite code")).getOrThrow() - assertTrue(request.isRingSignup) + assertTrue(request.isSignup) assertEquals("homeserver", request.homeserverPublicKey) assertEquals("invite code", request.signupToken) assertEquals("https://relay.example/inbox/", request.relay) @@ -26,6 +26,20 @@ class PubkyAuthRequestTest { ) } + @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( @@ -34,7 +48,7 @@ class PubkyAuthRequestTest { ) invalidUrls.forEach { url -> - assertIs(PubkyAuthRequest.parseRingSignup(url).exceptionOrNull()) + assertIs(PubkyAuthRequest.parseSignup(url).exceptionOrNull()) } } @@ -80,7 +94,7 @@ class PubkyAuthRequestTest { capabilities = "/pub/bitkit.to/:rw", ).getOrThrow() - assertFalse(request.isRingSignup) + assertFalse(request.isSignup) assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } @@ -298,4 +312,8 @@ class PubkyAuthRequestTest { "&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 c721f361ec..51ce7eb627 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -123,7 +123,7 @@ class PubkyRepoTest : BaseUnitTest() { events += "register" registeredSession } - whenever(pubkyService.approveRingAuth(request.authorizationUrl, "secret")).thenAnswer { + whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")).thenAnswer { events += "authorize" } whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { events += "activate" } @@ -143,7 +143,7 @@ class PubkyRepoTest : BaseUnitTest() { val request = ringSignupRequest() stubSignupKeys() whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) - whenever(pubkyService.approveRingAuth(request.authorizationUrl, "secret")) + whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")) .thenThrow(IllegalStateException("authorization failed")) assertTrue(sut.approveSignupAuth(request).isFailure) @@ -168,6 +168,20 @@ class PubkyRepoTest : BaseUnitTest() { 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() @@ -1652,11 +1666,15 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.publicKeyFromSecret("secret")).thenReturn(VALID_SELF_KEY) } - private fun ringSignupRequest() = PubkyAuthRequest.parseRingSignup( + 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/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index af85469b60..9c28ad7766 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 @@ -571,7 +571,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), serviceNames = listOf("paykit"), bitkitClaim = bitkitClaim, - homeserverPublicKey = if (PubkyAuthRequest.isRingSignupUrl(authUrl)) "homeserver" else null, + 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 f56e9c5e3b..21095ef4d0 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 @@ -78,6 +79,7 @@ import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest @@ -221,6 +223,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val signupAuthUrl = "pubkyring://signup?hs=homeserver&relay=https://relay&secret=request&caps=/pub/example/:rw" + 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) @@ -1924,18 +1928,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `global scanner accepts Ring signup without an existing identity`() = test { enablePaykitUi() - scanSignup() + + scanSignup(signupAuthUrl) assertEquals(Sheet.PubkyAuth(signupAuthUrl), sut.currentSheet.value) verify(pubkyRepo, never()).hasSecretKey() } + @Test + fun `global scanner processes direct signup without auth sheet`() = test { + enablePaykitUi() + listOf(directSignupAuthUrl, legacyDirectSignupAuthUrl).forEach { authUrl -> + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(Result.success(Unit)) + + scanSignup(authUrl) + + assertNull(sut.currentSheet.value) + verifyBlocking(pubkyRepo) { approveSignupAuth(request) } + } + } + @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() + scanSignup(directSignupAuthUrl) assertNull(sut.currentSheet.value) verify(pubkyRepo, never()).hasSecretKey() @@ -1944,7 +1964,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @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() @@ -5079,10 +5099,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaykitEnabled.value = true } - private suspend fun TestScope.scanSignup() { + private suspend fun TestScope.scanSignup(authUrl: String = signupAuthUrl) { sut.showScannerSheet() advanceUntilIdle() - sut.onScannerSheetResult(signupAuthUrl) + sut.onScannerSheetResult(authUrl) advanceUntilIdle() } diff --git a/changelog.d/next/1224.added.md b/changelog.d/next/1224.added.md index 8aab2c4bb0..fe1fcd278c 100644 --- a/changelog.d/next/1224.added.md +++ b/changelog.d/next/1224.added.md @@ -1 +1 @@ -Added support for creating a Pubky identity from Pubky Ring signup requests. +Added support for creating a Pubky identity from app-authorized and direct Pubky signup requests. From 800dfcf83a8cfef5cc4b6937de0200f659f0cad7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 07:46:41 -0500 Subject: [PATCH 04/14] fix: complete pubky signup handoff --- .../java/to/bitkit/models/PubkyAuthRequest.kt | 12 ++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 23 ++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 45 ++++++++++++------- .../to/bitkit/models/PubkyAuthRequestTest.kt | 35 ++++++++------- .../viewmodels/AppViewModelSendFlowTest.kt | 10 +++-- 5 files changed, 87 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index b713cb80ec..be10906204 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -117,14 +117,14 @@ data class PubkyAuthRequest( fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false) fun isDirectSignupUrl(rawUrl: String): Boolean = - runCatching { URI(rawUrl).isDirectSignupRequest() }.getOrDefault(false) + parseSignup(rawUrl).getOrNull()?.let { it.authorizationUrl == null } ?: 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.scheme.equals("pubkyring", ignoreCase = true) + 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 "" @@ -161,6 +161,14 @@ data class PubkyAuthRequest( 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) }, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 48adf172d4..0dad141b08 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -7,8 +7,11 @@ import android.content.Intent import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.rememberDrawerState @@ -27,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -63,8 +67,11 @@ import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState import to.bitkit.ui.Routes.ExternalConnection import to.bitkit.ui.components.AuthCheckScreen +import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.DefaultSheetContainerColor import to.bitkit.ui.components.DrawerMenu +import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SheetHandlePlacement import to.bitkit.ui.components.SheetHost @@ -456,6 +463,7 @@ fun ContentView( val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + val isCompletingPubkySignup by appViewModel.isCompletingPubkySignup.collectAsStateWithLifecycle() val hwSendViewModel = hiltViewModel() val hwSendUiState by hwSendViewModel.uiState.collectAsStateWithLifecycle() val canDismissSheet = currentSheet !is Sheet.Send || @@ -719,6 +727,21 @@ fun ContentView( onOpenWidgetsSheet = { appViewModel.showSheet(Sheet.Widgets()) }, modifier = Modifier.align(Alignment.TopEnd) ) + + if (isCompletingPubkySignup) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Colors.Black), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) + HorizontalSpacer(12.dp) + BodyM(text = stringResource(R.string.profile__deriving_keys), color = Colors.White64) + } + } + } } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7f585e57bd..8cd5ab3f4e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -325,6 +325,8 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() + private val _isCompletingPubkySignup = MutableStateFlow(false) + val isCompletingPubkySignup = _isCompletingPubkySignup.asStateFlow() val pendingPaymentRequests = paykitPaymentRequestRepo.pendingRequests val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets @@ -4719,23 +4721,32 @@ class AppViewModel @Inject constructor( private suspend fun handleDirectPubkySignup(authUrl: String) { hideSheet() - val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = it.localizedPubkyAuthMessage(context), - ) - return - } - pubkyRepo.approveSignupAuth(request).onFailure { - val alreadySignedIn = it is PubkyAlreadySignedInError - ToastEventBus.send( - type = if (alreadySignedIn) Toast.ToastType.INFO else Toast.ToastType.ERROR, - title = context.getString( - if (alreadySignedIn) R.string.pubky_auth__already_signed_in else R.string.profile__auth_error_title, - ), - description = if (alreadySignedIn) null else it.localizedPubkyAuthMessage(context), - ) + _isCompletingPubkySignup.value = true + try { + val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__auth_error_title), + description = it.localizedPubkyAuthMessage(context), + ) + return + } + pubkyRepo.approveSignupAuth(request).onFailure { + val alreadySignedIn = it is PubkyAlreadySignedInError + ToastEventBus.send( + type = if (alreadySignedIn) Toast.ToastType.INFO else Toast.ToastType.ERROR, + title = context.getString( + if (alreadySignedIn) { + R.string.pubky_auth__already_signed_in + } else { + R.string.profile__auth_error_title + }, + ), + description = if (alreadySignedIn) null else it.localizedPubkyAuthMessage(context), + ) + } + } finally { + _isCompletingPubkySignup.value = false } } diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 2db0a79abc..c9102a65e2 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -11,19 +11,22 @@ import kotlin.test.assertTrue class PubkyAuthRequestTest { @Test - fun `parse Ring signup preserves registration and authorization details`() { - val request = PubkyAuthRequest.parseSignup(ringSignupUrl("invite code")).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, - ) + 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, + ) + assertFalse(PubkyAuthRequest.isDirectSignupUrl(request.rawUrl)) + } } @Test @@ -45,6 +48,7 @@ class PubkyAuthRequestTest { val invalidUrls = listOf( ringSignupUrl().replace("&secret=secret", ""), "${ringSignupUrl()}&hs=other", + directSignupUrl("signup") + "&relay=https%3A%2F%2Frelay.example", ) invalidUrls.forEach { url -> @@ -95,6 +99,7 @@ class PubkyAuthRequestTest { ).getOrThrow() assertFalse(request.isSignup) + assertFalse(PubkyAuthRequest.isDirectSignupUrl(request.rawUrl)) assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } @@ -307,8 +312,8 @@ class PubkyAuthRequestTest { return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims" } - private fun ringSignupUrl(signupToken: String? = null): String = - "pubkyring://signup?hs=homeserver" + + 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() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 21095ef4d0..b61a16871f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -223,6 +223,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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" @@ -1926,12 +1927,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `global scanner accepts Ring signup without an existing identity`() = test { + fun `global scanner accepts authorized signup without an existing identity`() = test { enablePaykitUi() - scanSignup(signupAuthUrl) - - assertEquals(Sheet.PubkyAuth(signupAuthUrl), sut.currentSheet.value) + listOf(signupAuthUrl, legacyAuthorizedSignupAuthUrl).forEach { authUrl -> + scanSignup(authUrl) + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + } verify(pubkyRepo, never()).hasSecretKey() } From 2a59eb5c0e9674d0e9cf069db6bfb854941cad5b Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 17:12:07 -0500 Subject: [PATCH 05/14] fix: recover failed pubky signup --- .../java/to/bitkit/repositories/PubkyRepo.kt | 31 +++++++++++++++++-- .../to/bitkit/services/PaykitSdkService.kt | 27 +++++++++++++--- .../to/bitkit/repositories/PubkyRepoTest.kt | 31 +++++++++++++++++-- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 3e98aba90c..92b7a13728 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1004,18 +1004,43 @@ class PubkyRepo @Inject constructor( 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) } - settingsStore.setPubkyProfileSetupPending(true) - pubkyService.activateRegisteredIdentity(registeredSession) + var activated = false + try { + pubkyService.activateRegisteredIdentity(registeredSession) + activated = true + } finally { + if (!activated) { + withContext(NonCancellable) { + settingsStore.setPubkyProfileSetupPending(false) + } + } + } - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = 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) + } + _publicKey.update { null } + _authState.update { PubkyAuthState.Idle } + } + } + } notifyBackupStateChanged() } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index ea864d842d..55beff2290 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 @@ -285,11 +287,17 @@ class PaykitSdkService @Inject constructor( isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } operationMutex.withLock { - activateBootstrapResult( - result = result, - previousPublicKey = previousPublicKey, - shouldStoreLocalSecret = true, - ) + var activated = false + try { + activateBootstrapResult( + result = result, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = true, + ) + activated = true + } finally { + if (!activated) clearRegisteredIdentityActivationLocked() + } } notifyBackupStateChanged() } @@ -893,6 +901,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) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 51ce7eb627..326037e581 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -153,18 +153,18 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `Ring signup marks profile setup pending before activating the registered session`() = 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 { - assertTrue(profileSetupPending.value) throw TestAppError("activation failed") } assertTrue(sut.approveSignupAuth(request).isFailure) - assertTrue(profileSetupPending.value) + assertFalse(profileSetupPending.value) assertNull(sut.publicKey.value) } @@ -634,6 +634,31 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { forgetSessionAccess() } } + @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() From f8103f71b54e9b3d134b5e16b6123da5d44c43a5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 6 Sep 2026 17:01:11 +0200 Subject: [PATCH 06/14] fix: harden pubky signup recovery --- .../java/to/bitkit/repositories/PubkyRepo.kt | 6 +++--- .../services/PubkyAuthHandlerRegistrar.kt | 4 ++-- app/src/main/java/to/bitkit/ui/ContentView.kt | 4 +++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 21 +++++++++++++++++++ .../services/PubkyAuthHandlerRegistrarTest.kt | 8 +++---- .../viewmodels/AppViewModelSendFlowTest.kt | 13 +++++++++++- 7 files changed, 46 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 92b7a13728..817d162579 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -969,10 +969,11 @@ class PubkyRepo @Inject constructor( managedSecretKeyFor(publicKey) != null }.getOrDefault(false) - fun hasIdentity(): Boolean = + 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) { @@ -1036,8 +1037,7 @@ class PubkyRepo @Inject constructor( .onFailure { Logger.warn("Failed to roll back Pubky signup session", it, context = TAG) } - _publicKey.update { null } - _authState.update { PubkyAuthState.Idle } + clearLocalState() } } } diff --git a/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt index d0a4df8d1c..b3f1a974f7 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 Bitkit as a `pubkyauth` handler for signup or locally managed authorization. */ @Singleton internal class PubkyAuthHandlerRegistrar @Inject constructor( @ApplicationContext private val context: Context, @@ -92,4 +92,4 @@ internal fun canHandlePubkyAuth( isPaykitUiEnabled: Boolean, hasIdentity: Boolean, hasSecretKey: Boolean, -): Boolean = isPaykitUiEnabled && hasIdentity && hasSecretKey +): Boolean = isPaykitUiEnabled && (!hasIdentity || hasSecretKey) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 0dad141b08..fad53263e3 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -200,6 +200,7 @@ import to.bitkit.ui.settings.support.ReportIssueScreen import to.bitkit.ui.settings.support.SupportScreen import to.bitkit.ui.settings.transactionSpeed.CustomFeeSettingsScreen import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen +import to.bitkit.ui.shared.util.blockPointerInputPassthrough import to.bitkit.ui.sheets.BTCPayConnectionSheet import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute @@ -732,7 +733,8 @@ fun ContentView( Box( modifier = Modifier .fillMaxSize() - .background(Colors.Black), + .background(Colors.Black) + .blockPointerInputPassthrough(), contentAlignment = Alignment.Center, ) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 8cd5ab3f4e..fa1cd005d3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4751,7 +4751,7 @@ class AppViewModel @Inject constructor( } private suspend fun rejectPubkySignupForExistingIdentity(): Boolean { - val hasIdentity = runCatching { pubkyRepo.hasIdentity() }.getOrElse { + val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() }.getOrElse { ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.profile__auth_error_title), diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 326037e581..ecd1cce0c5 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -195,6 +195,27 @@ class PubkyRepoTest : BaseUnitTest() { 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")) diff --git a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt index a663b8bac7..6fa802362d 100644 --- a/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt +++ b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt @@ -70,13 +70,13 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { } @Test - fun `handler is disabled without an identity`() = test { + fun `handler is enabled for signup without an identity`() = test { isPaykitEnabled.value = true createSut().start(backgroundScope) runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_ENABLED) verify(pubkyRepo, never()).hasSecretKey() } @@ -93,7 +93,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { } @Test - fun `handler is disabled when the local identity is removed`() = test { + fun `handler stays enabled for signup when the local identity is removed`() = test { isPaykitEnabled.value = true publicKey.value = "pubkylocal" whenever(pubkyRepo.hasSecretKey()).thenReturn(true) @@ -104,7 +104,7 @@ class PubkyAuthHandlerRegistrarTest : BaseUnitTest() { publicKey.value = null runCurrent() - verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_DISABLED) + verifyComponentState(PackageManager.COMPONENT_ENABLED_STATE_ENABLED) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b61a16871f..9b8c487448 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -287,7 +287,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.hasIdentity() }.thenAnswer { pubkyPublicKey.value != null } whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) whenever { refreshContactPaykitReceivers(any()) }.thenReturn(Result.success(Unit)) whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } @@ -1926,6 +1926,17 @@ 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() From 5a86731eb81a42e95d832bb91832e9c82c827cfd Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 20:14:37 +0300 Subject: [PATCH 07/14] fix: require consent and local auth for pubky signup --- .../java/to/bitkit/models/PubkyAuthRequest.kt | 3 - .../java/to/bitkit/repositories/PubkyRepo.kt | 3 +- .../to/bitkit/services/PaykitSdkService.kt | 22 ++++-- app/src/main/java/to/bitkit/ui/ContentView.kt | 60 +++++++-------- .../screens/profile/PubkyAuthApprovalSheet.kt | 28 ++++++- .../profile/PubkyAuthApprovalViewModel.kt | 2 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 48 ++---------- app/src/main/res/values/strings.xml | 2 + .../to/bitkit/models/PubkyAuthRequestTest.kt | 2 - .../to/bitkit/repositories/PubkyRepoTest.kt | 16 +++- .../bitkit/services/PaykitSdkServiceTest.kt | 73 +++++++++++++++++++ .../test/java/to/bitkit/ui/ContentViewTest.kt | 22 ++++++ .../profile/PubkyAuthApprovalViewModelTest.kt | 36 +++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 33 +++++++-- 14 files changed, 251 insertions(+), 99 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index be10906204..14e1f70d2c 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -116,9 +116,6 @@ data class PubkyAuthRequest( fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false) - fun isDirectSignupUrl(rawUrl: String): Boolean = - parseSignup(rawUrl).getOrNull()?.let { it.authorizationUrl == null } ?: false - fun parseSignup(rawUrl: String): Result = runCatching { val uri = URI(rawUrl) require(uri.isSignupRequest()) { "Unsupported Pubky signup URL" } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 817d162579..fefe46871f 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -554,7 +554,7 @@ class PubkyRepo @Inject constructor( tags: List, avatarBytes: ByteArray?, ): Result { - if (settingsStore.isPubkyProfileSetupPending.first()) { + if (settingsStore.isPubkyProfileSetupPending.first() && _publicKey.value != null) { return runSuspendCatching { withContext(ioDispatcher) { val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" } @@ -568,6 +568,7 @@ class PubkyRepo @Inject constructor( return try { val result = runSuspendCatching { withContext(ioDispatcher) { + settingsStore.setPubkyProfileSetupPending(false) val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow() val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 55beff2290..088e0221d9 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -160,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() { @@ -958,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/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index fad53263e3..41805f92f6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -7,11 +7,8 @@ import android.content.Intent import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.rememberDrawerState @@ -30,7 +27,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -67,11 +63,8 @@ import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState import to.bitkit.ui.Routes.ExternalConnection import to.bitkit.ui.components.AuthCheckScreen -import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.DefaultSheetContainerColor import to.bitkit.ui.components.DrawerMenu -import to.bitkit.ui.components.GradientCircularProgressIndicator -import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SheetHandlePlacement import to.bitkit.ui.components.SheetHost @@ -200,7 +193,6 @@ import to.bitkit.ui.settings.support.ReportIssueScreen import to.bitkit.ui.settings.support.SupportScreen import to.bitkit.ui.settings.transactionSpeed.CustomFeeSettingsScreen import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen -import to.bitkit.ui.shared.util.blockPointerInputPassthrough import to.bitkit.ui.sheets.BTCPayConnectionSheet import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute @@ -464,7 +456,6 @@ fun ContentView( val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() - val isCompletingPubkySignup by appViewModel.isCompletingPubkySignup.collectAsStateWithLifecycle() val hwSendViewModel = hiltViewModel() val hwSendUiState by hwSendViewModel.uiState.collectAsStateWithLifecycle() val canDismissSheet = currentSheet !is Sheet.Send || @@ -632,7 +623,7 @@ fun ContentView( ) { Box(modifier = Modifier.fillMaxSize()) { var isHomeCalculatorInputActive by remember { mutableStateOf(false) } - var didResumePendingPubkyProfileSetup by remember { mutableStateOf(false) } + val pubkyProfileSetupNavigation = remember { PubkyProfileSetupNavigation() } RootNavHost( navController = navController, @@ -660,16 +651,15 @@ fun ContentView( currentSheet, currentRoute, ) { - if (!isPubkyProfileSetupPending) { - didResumePendingPubkyProfileSetup = false - } val canNavigate = currentSheet == null && currentRoute != Routes.CreateProfile::class.qualifiedName - val shouldResumeProfileSetup = isPaykitEnabled && - isPubkyProfileSetupPending && - isProfileAuthenticated - if (shouldResumeProfileSetup && canNavigate && !didResumePendingPubkyProfileSetup) { - didResumePendingPubkyProfileSetup = true + if (pubkyProfileSetupNavigation.shouldNavigate( + isEnabled = isPaykitEnabled, + isPending = isPubkyProfileSetupPending, + isAuthenticated = isProfileAuthenticated, + canNavigate = canNavigate, + ) + ) { navController.navigateTo(Routes.CreateProfile) } } @@ -728,23 +718,27 @@ fun ContentView( onOpenWidgetsSheet = { appViewModel.showSheet(Sheet.Widgets()) }, modifier = Modifier.align(Alignment.TopEnd) ) + } + } +} - if (isCompletingPubkySignup) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Colors.Black) - .blockPointerInputPassthrough(), - contentAlignment = Alignment.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) - HorizontalSpacer(12.dp) - BodyM(text = stringResource(R.string.profile__deriving_keys), color = Colors.White64) - } - } - } +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 } } 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 ab8632c00b..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,8 +380,14 @@ private fun ColumnScope.ApprovalDetails( Column(modifier = Modifier.weight(1f)) { VerticalSpacer(26.dp) - DescriptionText(serviceName = uiState.serviceName) - VerticalSpacer(8.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), @@ -394,13 +400,27 @@ private fun ColumnScope.ApprovalDetails( 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 e95a4a704f..cd453ba99c 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 @@ -91,6 +91,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( ApprovalState.Authorize }, clientId = request.clientId, + homeserverPublicKey = request.homeserverPublicKey, serviceName = serviceName, permissions = request.permissions.toImmutableList(), bitkitClaim = request.bitkitClaim, @@ -327,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 fa1cd005d3..b8d5d9e14c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -163,7 +163,6 @@ import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo -import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo @@ -325,8 +324,6 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() - private val _isCompletingPubkySignup = MutableStateFlow(false) - val isCompletingPubkySignup = _isCompletingPubkySignup.asStateFlow() val pendingPaymentRequests = paykitPaymentRequestRepo.pendingRequests val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets @@ -4669,8 +4666,13 @@ class AppViewModel @Inject constructor( } if (PubkyAuthRequest.isProtocolUrl(uri.toString())) { - if (!isPaykitEnabled.value) return@launch - handlePubkyAuth(uri.toString()) + if (!isPaykitEnabled.value || !walletRepo.walletExists()) return@launch + launchScan( + source = ScanSource.DEEPLINK, + data = uri.toString(), + startDelay = SCREEN_TRANSITION_DELAY, + allowPubkyAuth = true, + ) return@launch } @@ -4695,11 +4697,6 @@ class AppViewModel @Inject constructor( val isSignup = PubkyAuthRequest.isSignupUrl(authUrl) if (isSignup && rejectPubkySignupForExistingIdentity()) return - if (PubkyAuthRequest.isDirectSignupUrl(authUrl)) { - handleDirectPubkySignup(authUrl) - return - } - if (!isSignup && pubkyRepo.publicKey.value == null) { ToastEventBus.send( type = Toast.ToastType.WARNING, @@ -4719,37 +4716,6 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } - private suspend fun handleDirectPubkySignup(authUrl: String) { - hideSheet() - _isCompletingPubkySignup.value = true - try { - val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = it.localizedPubkyAuthMessage(context), - ) - return - } - pubkyRepo.approveSignupAuth(request).onFailure { - val alreadySignedIn = it is PubkyAlreadySignedInError - ToastEventBus.send( - type = if (alreadySignedIn) Toast.ToastType.INFO else Toast.ToastType.ERROR, - title = context.getString( - if (alreadySignedIn) { - R.string.pubky_auth__already_signed_in - } else { - R.string.profile__auth_error_title - }, - ), - description = if (alreadySignedIn) null else it.localizedPubkyAuthMessage(context), - ) - } - } finally { - _isCompletingPubkySignup.value = false - } - } - private suspend fun rejectPubkySignupForExistingIdentity(): Boolean { val hasIdentity = runSuspendCatching { pubkyRepo.hasIdentity() }.getOrElse { ToastEventBus.send( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 40b4913792..0b0db04be4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -683,8 +683,10 @@ 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/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index c9102a65e2..38afcab01f 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -25,7 +25,6 @@ class PubkyAuthRequestTest { "&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw", request.authorizationUrl, ) - assertFalse(PubkyAuthRequest.isDirectSignupUrl(request.rawUrl)) } } @@ -99,7 +98,6 @@ class PubkyAuthRequestTest { ).getOrThrow() assertFalse(request.isSignup) - assertFalse(PubkyAuthRequest.isDirectSignupUrl(request.rawUrl)) assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index ecd1cce0c5..0e150b53df 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -41,6 +41,7 @@ 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 @@ -220,7 +221,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `identity check fails closed when secure storage cannot be read`() = test { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenThrow(IllegalStateException("unavailable")) - assertTrue(runCatching { sut.hasIdentity() }.isFailure) + assertTrue(runSuspendCatching { sut.hasIdentity() }.isFailure) } @Test @@ -655,6 +656,19 @@ 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 should preserve signup session when pending profile publication fails`() = test { val registeredSession = mock() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 5a74b7f34c..8453e0f83d 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 { 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/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index 3ca936d620..399136e3ac 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -14,6 +14,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 9c28ad7766..166caeed30 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,5 +1,6 @@ package to.bitkit.ui.screens.profile +import app.cash.turbine.test import android.content.Context import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException import kotlinx.coroutines.CompletableDeferred @@ -146,6 +147,41 @@ 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" diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 9b8c487448..7fd07e5883 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -79,7 +79,6 @@ import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType -import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest @@ -1949,17 +1948,37 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `global scanner processes direct signup without auth sheet`() = test { + fun `global scanner requires approval for direct signup`() = test { enablePaykitUi() listOf(directSignupAuthUrl, legacyDirectSignupAuthUrl).forEach { authUrl -> - val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() - whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) - whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(Result.success(Unit)) - 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) { approveSignupAuth(request) } + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) } + + sut.setIsAuthenticated(true) + advanceUntilIdle() + + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) } } } From d6149692e653ce7a36e8206427d457b56a5fe120 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 20:24:02 +0300 Subject: [PATCH 08/14] test: format signup consent regression --- .../ui/screens/profile/PubkyAuthApprovalViewModelTest.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 166caeed30..7b5e183022 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,7 +1,7 @@ package to.bitkit.ui.screens.profile -import app.cash.turbine.test import android.content.Context +import app.cash.turbine.test import com.synonym.paykit.PubkyAuthCompanionClaimApprovalException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -151,7 +151,11 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { 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 "" + 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)) From dc72c36b5f504fdbee293865e8404e6aa30b55c2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 20:58:22 +0300 Subject: [PATCH 09/14] fix: preserve pubky homeserver on restore --- .../java/to/bitkit/repositories/PubkyRepo.kt | 26 ++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 144 ++++++++++++++++++ 2 files changed, 160 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index fefe46871f..a6ce704c84 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -569,17 +569,23 @@ class PubkyRepo @Inject constructor( val result = runSuspendCatching { withContext(ioDispatcher) { settingsStore.setPubkyProfileSetupPending(false) - val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow() + 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 = publishIdentityProfile(name, bio, links, tags, avatarBytes) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 0e150b53df..287a411b6c 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -21,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 @@ -669,6 +671,148 @@ class PubkyRepoTest : BaseUnitTest() { 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() From 4b828cdbfcc09857d9cdbb2d42477880b5ec707a Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 8 Sep 2026 06:09:53 +0100 Subject: [PATCH 10/14] fix: route pubky ring signup links --- app/src/main/AndroidManifest.xml | 10 +++- .../to/bitkit/build/PubkyAuthManifestTest.kt | 50 +++++++++++++++++++ .../bitkit/services/PaykitSdkServiceTest.kt | 2 +- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c20fd33b3b..67880584bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -163,7 +163,7 @@ android:resource="@xml/shortcuts" /> - + + + + + + + () + val packageManager = application.packageManager + val alias = ComponentName(application.packageName, "to.bitkit.ui.MainActivityPubkyAuth") + val signup = Intent(Intent.ACTION_VIEW, "pubkyring://signup?hs=homeserver".toUri()) + .addCategory(Intent.CATEGORY_BROWSABLE) + .setPackage(application.packageName) + + assertTrue(packageManager.queryIntentActivities(signup, PackageManager.MATCH_DEFAULT_ONLY).isEmpty()) + + packageManager.setComponentEnabledSetting( + alias, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP, + ) + + listOf("pubkyring://signup?hs=homeserver", "pubkyauth://direct_signup?hs=homeserver", "pubkyauth://?caps=rw") + .forEach { + val resolved = packageManager.queryIntentActivities( + Intent(signup).setData(it.toUri()), + PackageManager.MATCH_DEFAULT_ONLY, + ).single().activityInfo + assertEquals(alias.className, resolved.name) + assertEquals("to.bitkit.ui.MainActivity", resolved.targetActivity) + assertTrue(resolved.exported) + } + + val signIn = Intent(signup).setData("pubkyring://auth".toUri()) + assertTrue(packageManager.queryIntentActivities(signIn, PackageManager.MATCH_DEFAULT_ONLY).isEmpty()) + + packageManager.setComponentEnabledSetting( + alias, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP, + ) + assertTrue(packageManager.queryIntentActivities(signup, PackageManager.MATCH_DEFAULT_ONLY).isEmpty()) + } + private fun parseManifest(path: Path) = DocumentBuilderFactory.newInstance() .newDocumentBuilder() .parse(path.toFile()) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 8453e0f83d..005e78b71e 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -80,7 +80,7 @@ class PaykitSdkServiceTest { } verify(blocking, never()).delete(any()) } else { - val thrown = assertFailsWith { service.activateRegisteredIdentity(result) } + 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) From 6df1707e1b87944f53dc5f1cd20755bb61f39ad0 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 8 Sep 2026 13:28:35 +0100 Subject: [PATCH 11/14] fix: separate pubky signup and authorization routes --- app/src/main/AndroidManifest.xml | 18 +++- .../services/PubkyAuthHandlerRegistrar.kt | 28 +++--- .../to/bitkit/build/PubkyAuthManifestTest.kt | 97 +++++++++++++------ .../services/PubkyAuthHandlerRegistrarTest.kt | 62 ++++++++---- 4 files changed, 143 insertions(+), 62 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67880584bd..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/services/PubkyAuthHandlerRegistrar.kt b/app/src/main/java/to/bitkit/services/PubkyAuthHandlerRegistrar.kt index b3f1a974f7..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 for signup or locally managed authorization. */ +/** 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,28 +79,23 @@ 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( isPaykitUiEnabled: Boolean, hasIdentity: Boolean, hasSecretKey: Boolean, -): Boolean = isPaykitUiEnabled && (!hasIdentity || hasSecretKey) +): Boolean = isPaykitUiEnabled && hasIdentity && hasSecretKey diff --git a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt index 4062674d8b..7796c7539f 100644 --- a/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt +++ b/app/src/test/java/to/bitkit/build/PubkyAuthManifestTest.kt @@ -38,53 +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 links resolve only through the enabled Pubky alias`() { + fun `signup and authorization links resolve only through their enabled aliases`() { val application = ApplicationProvider.getApplicationContext() val packageManager = application.packageManager - val alias = ComponentName(application.packageName, "to.bitkit.ui.MainActivityPubkyAuth") - val signup = Intent(Intent.ACTION_VIEW, "pubkyring://signup?hs=homeserver".toUri()) - .addCategory(Intent.CATEGORY_BROWSABLE) - .setPackage(application.packageName) - - assertTrue(packageManager.queryIntentActivities(signup, PackageManager.MATCH_DEFAULT_ONLY).isEmpty()) + 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( - alias, + signupAlias, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP, ) + assertRoutes(packageManager, application.packageName, signupUrls, signupAlias) + assertRoutes(packageManager, application.packageName, authUrls + unrelatedUrls, null) - listOf("pubkyring://signup?hs=homeserver", "pubkyauth://direct_signup?hs=homeserver", "pubkyauth://?caps=rw") - .forEach { - val resolved = packageManager.queryIntentActivities( - Intent(signup).setData(it.toUri()), - PackageManager.MATCH_DEFAULT_ONLY, - ).single().activityInfo - assertEquals(alias.className, resolved.name) - assertEquals("to.bitkit.ui.MainActivity", resolved.targetActivity) - assertTrue(resolved.exported) - } - - val signIn = Intent(signup).setData("pubkyring://auth".toUri()) - assertTrue(packageManager.queryIntentActivities(signIn, PackageManager.MATCH_DEFAULT_ONLY).isEmpty()) + 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( - alias, + authAlias, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP, ) - assertTrue(packageManager.queryIntentActivities(signup, PackageManager.MATCH_DEFAULT_ONLY).isEmpty()) + 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/services/PubkyAuthHandlerRegistrarTest.kt b/app/src/test/java/to/bitkit/services/PubkyAuthHandlerRegistrarTest.kt index 6fa802362d..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 enabled for signup 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_ENABLED) + 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 stays enabled for signup 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_ENABLED) + 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), + ) + } } } From 7e5d24bbf3a1aedecd1909c847869f7904737b3c Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 8 Sep 2026 19:45:26 +0100 Subject: [PATCH 12/14] fix: clear rejected contact payment context --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +-- .../profile/PubkyAuthApprovalViewModelTest.kt | 4 +-- .../viewmodels/AppViewModelSendFlowTest.kt | 32 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b8d5d9e14c..6e072a709a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2651,13 +2651,13 @@ class AppViewModel @Inject constructor( } private suspend fun clearRejectedContactPaymentContext(context: ContactPaymentContext?) { - val request = context?.incomingPaymentRequest ?: return + if (context == null) return synchronized(contactPaymentContextLock) { if (activeContactPaymentContext != context) return activeContactPaymentContext = null preparedContactPaymentContext = null } - markIncomingPaymentRequestPresented(request) + context.incomingPaymentRequest?.let { markIncomingPaymentRequestPresented(it) } } private suspend fun markIncomingPaymentRequestPresented(request: PaykitPaymentRequest) { 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 7b5e183022..96bbfb9713 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 @@ -193,8 +193,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { authUrl = authUrl, capabilities = "/pub/example/:rw", ) - whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn(Result.success(request)) - whenever { pubkyRepo.approveSignupAuth(request) }.thenReturn(Result.success(Unit)) + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(Result.success(Unit)) val sut = createSut() sut.load(authUrl) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 7fd07e5883..20f69313ed 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2037,6 +2037,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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() From abd798332e029c624b51cedcc0edbce01a9db7bc Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 8 Sep 2026 20:00:12 +0100 Subject: [PATCH 13/14] fix: log superseded pubky approval failures --- .../to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 cd453ba99c..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 @@ -294,6 +294,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( } private suspend fun handleApprovalFailure(error: Throwable, authUrl: String) { + if (error !is PubkyAlreadySignedInError) Logger.error("Auth approval failed", error, context = TAG) if (_uiState.value.authUrl != authUrl) return if (error is PubkyAlreadySignedInError) { ToastEventBus.send( @@ -303,7 +304,6 @@ class PubkyAuthApprovalViewModel @Inject constructor( _effects.emit(PubkyAuthApprovalEffect.Dismiss) return } - Logger.error("Auth approval failed", error, context = TAG) _uiState.update { it.copy(state = ApprovalState.Authorize) } ToastEventBus.send( type = Toast.ToastType.ERROR, From f2bee105703e4b7e8b507809191519a9e92b9f94 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 8 Sep 2026 21:13:19 +0100 Subject: [PATCH 14/14] test: cover superseded pubky approval failures --- .../profile/PubkyAuthApprovalViewModelTest.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 96bbfb9713..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,7 @@ 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 @@ -10,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 @@ -105,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"