diff --git a/CHANGELOG.md b/CHANGELOG.md index 43fec9fef..67cc2bb27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve Pubky profile restore, contact editing, and contact routing flows #905 ### Fixed +- Return to Bitkit after Pubky Ring approval, cancellation, or error callbacks #917 - Polish Terms of Use screen padding to match iOS #903 ## [2.2.0] - 2026-04-07 diff --git a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt index 1992e08cd..629b84842 100644 --- a/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt +++ b/app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt @@ -17,8 +17,8 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay -import to.bitkit.repositories.ActivityRepo import to.bitkit.models.msatCeilOf +import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.utils.Logger import javax.inject.Inject diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt index fe9a86efc..6572848a8 100644 --- a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -26,7 +26,6 @@ import to.bitkit.ext.amountOnClose import to.bitkit.ext.toUserMessage import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BlocktankNotificationType -import to.bitkit.models.msatCeilOf import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived import to.bitkit.models.BlocktankNotificationType.incomingHtlc import to.bitkit.models.BlocktankNotificationType.mutualClose @@ -36,6 +35,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NotificationDetails +import to.bitkit.models.msatCeilOf import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo diff --git a/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt b/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt new file mode 100644 index 000000000..49763311e --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt @@ -0,0 +1,49 @@ +package to.bitkit.models + +import android.net.Uri + +sealed interface PubkyRingAuthCallback { + companion object { + private const val BITKIT_SCHEME = "bitkit" + private const val PUBKY_AUTH_HOST = "pubky-auth" + private const val SUCCESS_PATH = "/success" + private const val CANCEL_PATH = "/cancel" + private const val ERROR_PATH = "/error" + private const val ERROR_MESSAGE_PARAM = "errorMessage" + + fun parse(uri: Uri): PubkyRingAuthCallback? { + if (uri.scheme != BITKIT_SCHEME || uri.host != PUBKY_AUTH_HOST) return null + + return when (uri.path) { + SUCCESS_PATH -> Success + CANCEL_PATH -> Cancel + ERROR_PATH -> Error(uri.getQueryParameter(ERROR_MESSAGE_PARAM)) + else -> null + } + } + } + + data object Success : PubkyRingAuthCallback + data object Cancel : PubkyRingAuthCallback + data class Error(val message: String?) : PubkyRingAuthCallback +} + +object PubkyRingAuthUrlBuilder { + const val SUCCESS_CALLBACK = "bitkit://pubky-auth/success" + const val CANCEL_CALLBACK = "bitkit://pubky-auth/cancel" + const val ERROR_CALLBACK = "bitkit://pubky-auth/error" + const val SOURCE = "Bitkit" + + fun addCallbacks(authUrl: String): String? { + val uri = Uri.parse(authUrl) + if (uri.scheme.isNullOrBlank()) return null + + return uri.buildUpon() + .appendQueryParameter("x-success", SUCCESS_CALLBACK) + .appendQueryParameter("x-cancel", CANCEL_CALLBACK) + .appendQueryParameter("x-error", ERROR_CALLBACK) + .appendQueryParameter("x-source", SOURCE) + .build() + .toString() + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 6cf2104c2..1ab2c744b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -12,9 +12,11 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map @@ -76,6 +78,8 @@ class PubkyRepo @Inject constructor( private var isServiceInitialized = false private val _authState = MutableStateFlow(PubkyAuthState.Idle) + private val _authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1) + val authCancelEvents = _authCancelEvents.asSharedFlow() private val _profile = MutableStateFlow(null) val profile: StateFlow = _profile.asStateFlow() @@ -271,6 +275,7 @@ class PubkyRepo @Inject constructor( runCatching { withContext(ioDispatcher) { pubkyService.cancelAuth() } }.onFailure { Logger.warn("Failed to cancel auth", it, context = TAG) } + _authCancelEvents.tryEmit(Unit) _authState.update { PubkyAuthState.Idle } } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 199e2ff3e..dde2ee0a4 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -32,8 +32,8 @@ import to.bitkit.ext.toHex import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS import to.bitkit.models.AddressModel import to.bitkit.models.BalanceState -import to.bitkit.models.msatFloorOf import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING +import to.bitkit.models.msatFloorOf import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 4349eced4..527dff93d 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -72,11 +72,11 @@ import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.env.Env import to.bitkit.ext.amountSats -import to.bitkit.models.msatFloorOf import to.bitkit.ext.channelId import to.bitkit.ext.create import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.addressTypeFromAddress +import to.bitkit.models.msatFloorOf import to.bitkit.models.toCoreNetwork import to.bitkit.utils.AppError import to.bitkit.utils.Logger diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 74e1a2f3a..2885defad 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -49,8 +49,8 @@ import to.bitkit.env.Env import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.ext.uByteList import to.bitkit.ext.uri -import to.bitkit.models.msatFloorOf import to.bitkit.models.OpenChannelResult +import to.bitkit.models.msatFloorOf import to.bitkit.models.toAddressType import to.bitkit.utils.AppError import to.bitkit.utils.LdkError diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 1b6f70840..43b87cd91 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -50,11 +50,11 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.formatToString import to.bitkit.ext.uri -import to.bitkit.models.msatFloorOf import to.bitkit.models.NodeLifecycleState import to.bitkit.models.NodePeer import to.bitkit.models.alias import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.msatFloorOf import to.bitkit.repositories.LightningState import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt index 4c352d2fe..1c1c783fe 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.models.PubkyRingAuthUrlBuilder import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.shared.toast.ToastEventBus @@ -41,6 +42,16 @@ class PubkyChoiceViewModel @Inject constructor( private var approvalJob: Job? = null + init { + viewModelScope.launch { + pubkyRepo.authCancelEvents.collect { + approvalJob?.cancel() + approvalJob = null + _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) } + } + } + } + override fun onCleared() { super.onCleared() if (_uiState.value.isWaitingForRing) { @@ -64,7 +75,7 @@ class PubkyChoiceViewModel @Inject constructor( pubkyRepo.startAuthentication() .onSuccess { authUrl -> - val ringIntent = createRingAuthIntent(authUrl) + val ringIntent = createRingAuthIntent(PubkyRingAuthUrlBuilder.addCallbacks(authUrl) ?: authUrl) if (!canOpenWithRing(ringIntent)) { cancelAuthAndShowRingDialog() return@launch diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 0b93407ac..a41243741 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -60,8 +60,8 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails import to.bitkit.ext.setClipboardText -import to.bitkit.models.msatFloorOf import to.bitkit.models.Toast +import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Caption13Up diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index 89f42eb0f..4c1913954 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -47,8 +47,8 @@ import kotlinx.collections.immutable.toImmutableList import to.bitkit.R import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails -import to.bitkit.models.msatFloorOf import to.bitkit.models.formatToModernDisplay +import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 46d973105..3c6568984 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -97,6 +97,7 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat +import to.bitkit.models.PubkyRingAuthCallback import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -2525,6 +2526,11 @@ class AppViewModel @Inject constructor( return@launch } + PubkyRingAuthCallback.parse(uri)?.let { + handlePubkyRingAuthCallback(it) + return@launch + } + if (uri.scheme == PUBKYAUTH_SCHEME) { handlePubkyAuth(uri.toString()) return@launch @@ -2546,6 +2552,30 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } + private suspend fun handlePubkyRingAuthCallback(callback: PubkyRingAuthCallback) { + when (callback) { + PubkyRingAuthCallback.Success -> { + Logger.info("Received Pubky Ring auth success callback", context = TAG) + } + PubkyRingAuthCallback.Cancel -> { + Logger.info("Received Pubky Ring auth cancel callback", context = TAG) + pubkyRepo.cancelAuthentication() + } + is PubkyRingAuthCallback.Error -> { + Logger.warn( + "Received Pubky Ring auth error callback with message '${callback.message.orEmpty()}'", + context = TAG, + ) + pubkyRepo.cancelAuthentication() + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__auth_error_title), + description = callback.message ?: context.getString(R.string.other__qr_error_text), + ) + } + } + } + // TODO Temporary fix while these schemes can't be decoded https://github.com/synonymdev/bitkit-core/issues/70 private fun String.removeLightningSchemes(): String = LIGHTNING_SCHEME_PATTERNS.fold(this) { acc, regex -> acc.replace(regex, "") diff --git a/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt b/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt new file mode 100644 index 000000000..3d043fc2a --- /dev/null +++ b/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt @@ -0,0 +1,49 @@ +package to.bitkit.models + +import androidx.core.net.toUri +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PubkyRingAuthCallbackTest { + @Test + fun `addCallbacks adds Ring x-callback params`() { + val url = checkNotNull( + PubkyRingAuthUrlBuilder.addCallbacks("pubkyauth://auth?relay=https%3A%2F%2Frelay.example"), + ) { "Auth URL should be valid" } + val uri = url.toUri() + + assertEquals("https://relay.example", uri.getQueryParameter("relay")) + assertEquals(PubkyRingAuthUrlBuilder.SUCCESS_CALLBACK, uri.getQueryParameter("x-success")) + assertEquals(PubkyRingAuthUrlBuilder.CANCEL_CALLBACK, uri.getQueryParameter("x-cancel")) + assertEquals(PubkyRingAuthUrlBuilder.ERROR_CALLBACK, uri.getQueryParameter("x-error")) + assertEquals(PubkyRingAuthUrlBuilder.SOURCE, uri.getQueryParameter("x-source")) + } + + @Test + fun `parse returns success cancel and error callbacks`() { + assertEquals( + PubkyRingAuthCallback.Success, + PubkyRingAuthCallback.parse("bitkit://pubky-auth/success".toUri()), + ) + assertEquals( + PubkyRingAuthCallback.Cancel, + PubkyRingAuthCallback.parse("bitkit://pubky-auth/cancel".toUri()), + ) + assertEquals( + PubkyRingAuthCallback.Error("Denied"), + PubkyRingAuthCallback.parse("bitkit://pubky-auth/error?errorMessage=Denied".toUri()), + ) + } + + @Test + fun `parse rejects other deeplinks`() { + assertNull(PubkyRingAuthCallback.parse("bitkit://wallet/success".toUri())) + assertNull(PubkyRingAuthCallback.parse("https://pubky-auth/success".toUri())) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt index 3678e1fb3..afb09beae 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.profile import android.content.Context import android.content.pm.PackageManager import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -28,6 +29,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { private val packageManager: PackageManager = mock() private val pubkyRepo: PubkyRepo = mock() private val pendingImportContacts = MutableStateFlow>(emptyList()) + private val authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1) private lateinit var sut: PubkyChoiceViewModel @@ -37,6 +39,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.common__error)).thenReturn("Error") whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed") whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts) + whenever(pubkyRepo.authCancelEvents).thenReturn(authCancelEvents) sut = PubkyChoiceViewModel( context = context, pubkyRepo = pubkyRepo,