Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package to.bitkit.models

import android.net.Uri

sealed interface PubkyRingAuthCallback {
companion object {
Comment thread
ben-kaufman marked this conversation as resolved.
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
}
Comment thread
ben-kaufman marked this conversation as resolved.

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()
}
}
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,6 +78,8 @@ class PubkyRepo @Inject constructor(
private var isServiceInitialized = false

private val _authState = MutableStateFlow(PubkyAuthState.Idle)
private val _authCancelEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val authCancelEvents = _authCancelEvents.asSharedFlow()

private val _profile = MutableStateFlow<PubkyProfile?>(null)
val profile: StateFlow<PubkyProfile?> = _profile.asStateFlow()
Expand Down Expand Up @@ -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 }
}

Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/repositories/WalletRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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, "")
Expand Down
49 changes: 49 additions & 0 deletions app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt
Original file line number Diff line number Diff line change
@@ -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()))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() {
private val packageManager: PackageManager = mock()
private val pubkyRepo: PubkyRepo = mock()
private val pendingImportContacts = MutableStateFlow<List<PubkyProfile>>(emptyList())
private val authCancelEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)

private lateinit var sut: PubkyChoiceViewModel

Expand All @@ -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,
Expand Down
Loading