From 91d083aef08e159bd0b69ddd9d9f940d11ca3ed7 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 10:45:00 +0300 Subject: [PATCH 1/9] feat: support pubky contact deep links --- README.md | 8 ++ .../java/to/bitkit/models/PubkyContactLink.kt | 22 +++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 45 +++++++++-- .../to/bitkit/models/PubkyContactLinkTest.kt | 45 +++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 80 +++++++++++++++++++ changelog.d/next/1320.added.md | 1 + journeys/README.md | 5 +- journeys/deeplinks/pubky-contact.xml | 20 +++++ 8 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/to/bitkit/models/PubkyContactLink.kt create mode 100644 app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt create mode 100644 changelog.d/next/1320.added.md create mode 100644 journeys/deeplinks/pubky-contact.xml diff --git a/README.md b/README.md index d89a536e3b..baa36f22ce 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ This repository contains the **native Android app** for Bitkit. +## Contact deep links + +Use `bitkit://contact?pubky=` to open the same flow as scanning a Pubky key. +The key can be the raw 52-character public key or include its `pubky` prefix; URL-encode the value. +Unknown keys open Add Contact, saved contacts open Contact Detail, and your own key opens Profile. +This requires an existing wallet with Paykit enabled and respects the wallet's unlock flow. +Opening the link does not save a contact or initiate a payment. + ## Development ### Prerequisites diff --git a/app/src/main/java/to/bitkit/models/PubkyContactLink.kt b/app/src/main/java/to/bitkit/models/PubkyContactLink.kt new file mode 100644 index 0000000000..98a10f4329 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/PubkyContactLink.kt @@ -0,0 +1,22 @@ +package to.bitkit.models + +import android.net.Uri + +object PubkyContactLink { + fun matches(uri: Uri): Boolean = + uri.scheme.equals("bitkit", ignoreCase = true) && uri.host.equals("contact", ignoreCase = true) + + fun publicKey(uri: Uri): String? { + if (!matches(uri)) return null + if (!uri.encodedAuthority.equals("contact", ignoreCase = true) || + !uri.path.isNullOrEmpty() || uri.fragment != null + ) { + return null + } + if (uri.queryParameterNames != setOf("pubky") || uri.encodedQuery.orEmpty().contains('&')) return null + val key = uri.getQueryParameters("pubky").singleOrNull() + ?.takeIf { it.length <= PubkyPublicKeyFormat.maximumInputLength } ?: return null + + return PubkyPublicKeyFormat.normalized(key) + } +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1503501d54..16efaf5056 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -118,6 +118,7 @@ import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState import to.bitkit.models.PubkyAuthRequest +import to.bitkit.models.PubkyContactLink import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkyRingAuthCallback @@ -2283,20 +2284,32 @@ class AppViewModel @Inject constructor( data: String, allowPubkyAuth: Boolean, ): Boolean { - if (source != ScanSource.DEEPLINK || !allowPubkyAuth) return true - if (!PubkyAuthRequest.isProtocolUrl(data)) return true + if (source != ScanSource.DEEPLINK) return true + val isContactLink = PubkyContactLink.matches(Uri.parse(data)) + if (!isContactLink && (!allowPubkyAuth || !PubkyAuthRequest.isProtocolUrl(data))) return true if (!PubkyAuthRequest.isSignupUrl(data)) { val isInitializationReady = withTimeoutOrNull(PubkyService.AUTHORIZATION_TIMEOUT) { pubkyRepo.awaitInitialization() + if (isContactLink && pubkyRepo.publicKey.value != null) { + pubkyRepo.contactsLoadVersion.first { it > 0 } + } true } ?: false if (!isInitializationReady) { Logger.warn("Timed out waiting for Pubky initialization", context = TAG) ToastEventBus.send( type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = context.getString(R.string.profile__auth_error_timeout), + title = context.getString( + if (isContactLink) R.string.other__scan_err_decoding else R.string.profile__auth_error_title, + ), + description = context.getString( + if (isContactLink) { + R.string.other__scan__error__generic + } else { + R.string.profile__auth_error_timeout + }, + ), ) return false } @@ -2825,7 +2838,18 @@ class AppViewModel @Inject constructor( ) = withContext(bgDispatcher) { if (rejectPubkyAuthScan(result, allowPubkyAuth, contactPaymentContext)) return@withContext - val input = result.removeLightningSchemes() + val input = if (routePubkyKeys && PubkyContactLink.matches(Uri.parse(result))) { + PubkyContactLink.publicKey(Uri.parse(result)) ?: run { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__scan_err_decoding), + description = context.getString(R.string.other__scan__error__generic), + ) + return@withContext + } + } else { + result.removeLightningSchemes() + } val contactPaymentProfile = activeContactPaymentProfile() val incomingPaymentRequest = activeIncomingPaymentRequest() @@ -2886,12 +2910,12 @@ class AppViewModel @Inject constructor( return@withContext } - if (routePubkyKeys && isPaykitEnabled.value) { + if (routePubkyKeys && isPaykitUiEnabledFromSettings()) { val route = resolvePastedPubkyRoute( input = input, ownPublicKey = pubkyRepo.publicKey.value, contacts = pubkyRepo.contacts.value, - isPaykitEnabled = isPaykitEnabled.value, + isPaykitEnabled = true, ) if (route != null) { @@ -5502,7 +5526,12 @@ class AppViewModel @Inject constructor( if (!walletRepo.walletExists()) return@launch - launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY) + launchScan( + source = ScanSource.DEEPLINK, + data = value, + startDelay = SCREEN_TRANSITION_DELAY, + routePubkyKeys = PubkyContactLink.matches(uri), + ) } fun consumeScreenDeepLink() { diff --git a/app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt b/app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt new file mode 100644 index 0000000000..8550b241c8 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt @@ -0,0 +1,45 @@ +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 PubkyContactLinkTest { + private val key = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + @Test + fun `accepts raw prefixed and encoded keys`() { + listOf(key.removePrefix("pubky"), key, key.uppercase(), key.replace("pubky", "%70ubky")).forEach { value -> + assertEquals(key, PubkyContactLink.publicKey("bitkit://contact?pubky=$value".toUri())) + } + } + + @Test + fun `rejects malformed links and non-key payloads`() { + listOf( + "https://contact?pubky=$key", + "bitkit://other?pubky=$key", + "bitkit://user@contact?pubky=$key", + "bitkit://contact:123?pubky=$key", + "bitkit://contact:invalid?pubky=$key", + "bitkit://contact/path?pubky=$key", + "bitkit://contact?pubky=$key#fragment", + "bitkit://contact", + "bitkit://contact?pubky=", + "bitkit://contact?pubky=$key&pubky=$key", + "bitkit://contact?pubky=$key&other=value", + "bitkit://contact?pubky=${key}extra", + "bitkit://contact?pubky=invalid", + "bitkit://contact?pubky=bitcoin%3Abc1example", + "bitkit://contact?pubky=pubkyauth%3A%2F%2Fsignin_grant", + ).forEach { link -> + assertNull(PubkyContactLink.publicKey(link.toUri()), link) + } + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 5f47896f2c..5d26b1921a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2713,6 +2713,86 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(coreService, never()).decode(any()) } + @Test + fun `contact deeplink opens add contact or own profile through scanner routing`() = test { + enablePaykitUi() + advanceUntilIdle() + sut.mainScreenEffect.test { + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem()) + advanceUntilIdle() + + pubkyPublicKey.value = testPublicKey + pubkyContactsLoadVersion.value = 1L + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + assertEquals(MainScreenEffect.Navigate(Routes.Profile), awaitItem()) + } + verify(coreService, never()).decode(any()) + } + + @Test + fun `contact deeplink waits for restored contacts and unlock before opening saved contact`() = test { + enablePaykitUi() + val initialized = CompletableDeferred() + whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() } + sut.mainScreenEffect.test { + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + runCurrent() + expectNoEvents() + + pubkyPublicKey.value = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + initialized.complete(Unit) + runCurrent() + expectNoEvents() + + settingsData.value = SettingsData(isPinEnabled = true) + sut.resetIsAuthenticatedState() + pubkyContacts.value = listOf(PubkyProfile.placeholder(testPublicKey)) + pubkyContactsLoadVersion.value = 1L + advanceUntilIdle() + expectNoEvents() + + sut.setIsAuthenticated(true) + assertEquals(MainScreenEffect.Navigate(Routes.ContactDetail(testPublicKey)), awaitItem()) + advanceUntilIdle() + expectNoEvents() + } + verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(coreService, never()).decode(any()) + } + + @Test + fun `contact deeplink does not route when Paykit is disabled or wallet is missing`() = test { + val intent = Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri()) + sut.mainScreenEffect.test { + sut.handleDeeplinkIntent(intent) + advanceUntilIdle() + expectNoEvents() + + enablePaykitUi() + whenever(walletRepo.walletExists()).thenReturn(false) + sut.handleDeeplinkIntent(intent) + advanceUntilIdle() + expectNoEvents() + } + verify(coreService, never()).decode(any()) + } + + @Test + fun `invalid contact deeplink cannot start payment or authorization`() = test { + enablePaykitUi() + sut.mainScreenEffect.test { + listOf("invalid", "bitcoin%3Abc1example", "pubkyauth%3A%2F%2Fsignin_grant").forEach { payload -> + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$payload".toUri())) + advanceUntilIdle() + } + expectNoEvents() + } + assertNull(sut.currentSheet.value) + verify(coreService, never()).decode(any()) + verify(pubkyRepo, never()).hasSecretKey() + } + @Test fun `manual address input rejects pubky when Paykit UI is disabled`() = test { sut.setSendEvent(SendEvent.AddressChange(testPublicKey)) diff --git a/changelog.d/next/1320.added.md b/changelog.d/next/1320.added.md new file mode 100644 index 0000000000..6892e6e0be --- /dev/null +++ b/changelog.d/next/1320.added.md @@ -0,0 +1 @@ +Open Pubky contact links directly in Bitkit to add a contact or view an existing one. diff --git a/journeys/README.md b/journeys/README.md index 97803bf46f..59f8f03bc5 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -119,7 +119,7 @@ fixtures, push notifications) live in each suite's README. | [backup-restore](backup-restore) | 1 | VSS restore keeps tags and closed channels; wipes the wallet | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [coin-selection](coin-selection) | 1 | Manual coin selection screen; needs 3+ on-chain UTXOs; no README | -| [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | +| [deeplinks](deeplinks) | 3 | Pubky contact handoff, plus `bitkit://screen/…` and sheet routing behind the dev-mode gate | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | | [home](home) | 1 | Pull to refresh on Home; checks the app log, no README | | [node-lifecycle](node-lifecycle) | 1 | Detached LDK restart completes; a cancelled RGS server change reconciles and recovers to Running; reads the app log; no README | @@ -159,7 +159,8 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | | `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | -| `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | +| `deeplinks/pubky-contact.xml` | ported — same contact routing and unlock behavior | +| `deeplinks/screen-deeplink.xml`, `deeplinks/sheet-deeplink.xml` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | `backup-restore/restore-keeps-tags-and-closed-channels.xml` | not ported yet — iOS already gates uploads across the whole restore (`AppScene.restoreFromMostRecentBackup` sets `BackupService.setRestoring(true)` before the timestamp probe), but still applies the three activity slices in one block (`BackupService.performFullRestoreFromLatestBackup`), which is the half this journey pins; port it with the iOS slice fix | | `shop/gift-card-category-titles.xml` | not ported — iOS still hardcodes the category names, and its route in has no screen deeplink | | `home/pull-to-refresh-rates.xml` | not ported — iOS does not refresh exchange rates on pull to refresh | diff --git a/journeys/deeplinks/pubky-contact.xml b/journeys/deeplinks/pubky-contact.xml new file mode 100644 index 0000000000..14723e61ac --- /dev/null +++ b/journeys/deeplinks/pubky-contact.xml @@ -0,0 +1,20 @@ + + Precondition: an onboarded wallet with Paykit enabled, a Pubky profile, one saved contact and PIN enabled. Have the saved contact's key, your own key and a valid unsaved key ready. Open each link with adb shell am start -a android.intent.action.VIEW -d "<link>" to.bitkit.dev. No payment or new contact save is needed. + + Open bitkit://contact?pubky=<unsaved-public-key> + Verify Add Contact opens with the supplied key prefilled and no contact saved + Navigate back to the wallet + Open bitkit://contact?pubky=<saved-contact-public-key> + Verify Contact Detail opens for that contact, not Add Contact or a payment sheet + Navigate back to the wallet + Open bitkit://contact?pubky=<own-public-key> + Verify Profile opens without adding yourself as a contact + Navigate back to the wallet, then terminate the app + Open bitkit://contact?pubky=<saved-contact-public-key> + Verify the PIN screen appears before any contact screen + Unlock the wallet and verify Contact Detail opens for the saved contact exactly once + Navigate back to the wallet + Open bitkit://contact?pubky=invalid + Verify no contact, payment or authorization flow opens + + From db45d0519a9f46e01893aea16d392b5e4ae4dd05 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 11:00:49 +0300 Subject: [PATCH 2/9] fix: reject malformed contact links before startup --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 19 ++++++++++++++++--- 2 files changed, 19 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 16efaf5056..d86ea64152 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2285,7 +2285,9 @@ class AppViewModel @Inject constructor( allowPubkyAuth: Boolean, ): Boolean { if (source != ScanSource.DEEPLINK) return true - val isContactLink = PubkyContactLink.matches(Uri.parse(data)) + val uri = Uri.parse(data) + val isContactLink = PubkyContactLink.matches(uri) + if (isContactLink && PubkyContactLink.publicKey(uri) == null) return true if (!isContactLink && (!allowPubkyAuth || !PubkyAuthRequest.isProtocolUrl(data))) return true if (!PubkyAuthRequest.isSignupUrl(data)) { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 5d26b1921a..907414381f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2779,16 +2779,29 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `invalid contact deeplink cannot start payment or authorization`() = test { + fun `invalid contact deeplink rejects without waiting for initialization or starting payment or auth`() = test { enablePaykitUi() + whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { awaitCancellation() } sut.mainScreenEffect.test { - listOf("invalid", "bitcoin%3Abc1example", "pubkyauth%3A%2F%2Fsignin_grant").forEach { payload -> - sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$payload".toUri())) + listOf( + "bitkit://contact", + "bitkit://contact?pubky=$testPublicKey&pubky=$testPublicKey", + "bitkit://contact?pubky=invalid", + "bitkit://contact?pubky=bitcoin%3Abc1example", + "bitkit://contact?pubky=pubkyauth%3A%2F%2Fsignin_grant", + ).forEach { link -> + settingsData.value = SettingsData(isPinEnabled = true) + sut.resetIsAuthenticatedState() + runCurrent() + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, link.toUri())) + runCurrent() + sut.setIsAuthenticated(true) advanceUntilIdle() } expectNoEvents() } assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).awaitInitialization() verify(coreService, never()).decode(any()) verify(pubkyRepo, never()).hasSecretKey() } From 94405b015b9cc0c158da68baefa715f515d24973 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 11:06:32 +0100 Subject: [PATCH 3/9] fix: reject recovery paths in contact links --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 5 ++-- .../viewmodels/AppViewModelSendFlowTest.kt | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index d86ea64152..cea4609e64 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5481,6 +5481,7 @@ class AppViewModel @Inject constructor( private fun processDeeplink(uri: Uri) = viewModelScope.launch { val value = uri.toString() + val isContactLink = PubkyContactLink.matches(uri) if (SamRockSetupRequest.isProtocolUrl(value)) { if (!walletRepo.walletExists()) return@launch @@ -5498,7 +5499,7 @@ class AppViewModel @Inject constructor( return@launch } - if (uri.isRecoveryModeDeeplink()) { + if (!isContactLink && uri.isRecoveryModeDeeplink()) { lightningRepo.setRecoveryMode(enabled = true) delay(SCREEN_TRANSITION_DELAY) mainScreenEffect( @@ -5532,7 +5533,7 @@ class AppViewModel @Inject constructor( source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY, - routePubkyKeys = PubkyContactLink.matches(uri), + routePubkyKeys = isContactLink, ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 907414381f..725ad18b8a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2781,15 +2781,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `invalid contact deeplink rejects without waiting for initialization or starting payment or auth`() = test { enablePaykitUi() + val invalidLinks = listOf( + "bitkit://contact", + "bitkit://contact?pubky=$testPublicKey&pubky=$testPublicKey", + "bitkit://contact/recovery-mode?pubky=$testPublicKey", + "bitkit://contact?pubky=invalid", + "bitkit://contact?pubky=bitcoin%3Abc1example", + "bitkit://contact?pubky=pubkyauth%3A%2F%2Fsignin_grant", + ) whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { awaitCancellation() } + whenever(context.getString(R.string.other__scan_err_decoding)).thenReturn("Decoding Error") + whenever(context.getString(R.string.other__scan__error__generic)).thenReturn("Unable to read data") sut.mainScreenEffect.test { - listOf( - "bitkit://contact", - "bitkit://contact?pubky=$testPublicKey&pubky=$testPublicKey", - "bitkit://contact?pubky=invalid", - "bitkit://contact?pubky=bitcoin%3Abc1example", - "bitkit://contact?pubky=pubkyauth%3A%2F%2Fsignin_grant", - ).forEach { link -> + invalidLinks.forEach { link -> settingsData.value = SettingsData(isPinEnabled = true) sut.resetIsAuthenticatedState() runCurrent() @@ -2803,7 +2807,15 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertNull(sut.currentSheet.value) verify(pubkyRepo, never()).awaitInitialization() verify(coreService, never()).decode(any()) + verify(lightningRepo, never()).setRecoveryMode(true) verify(pubkyRepo, never()).hasSecretKey() + verify(toastManager, times(invalidLinks.size)).enqueue( + check { + assertEquals(Toast.ToastType.ERROR, it.type) + assertEquals("Decoding Error", it.title) + assertEquals("Unable to read data", it.description) + } + ) } @Test From 54d174cbbbafe571b46883a09c6131e9e93229e2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 13:23:44 +0100 Subject: [PATCH 4/9] fix: release contact links after load failure --- .../java/to/bitkit/repositories/PubkyRepo.kt | 10 ++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 19 ++++++++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 20 +++++++++++++++++++ journeys/README.md | 2 +- 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 17817cdc95..cb940e6211 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -128,6 +128,9 @@ class PubkyRepo @Inject constructor( private val _contactsLoadVersion = MutableStateFlow(0L) val contactsLoadVersion: StateFlow = _contactsLoadVersion.asStateFlow() + private val _contactsLoadCompletionVersion = MutableStateFlow(0L) + val contactsLoadCompletionVersion: StateFlow = _contactsLoadCompletionVersion.asStateFlow() + private val _isLoadingContacts = MutableStateFlow(false) val isLoadingContacts: StateFlow = _isLoadingContacts.asStateFlow() @@ -836,7 +839,9 @@ class PubkyRepo @Inject constructor( } _contacts.update { loadedContacts } markContactsLoaded() + markContactsLoadCompleted() }.onFailure { + if (_publicKey.value == pk) markContactsLoadCompleted() Logger.error("Failed to load contacts", it, context = TAG) } } finally { @@ -1408,6 +1413,7 @@ class PubkyRepo @Inject constructor( _profile.update { null } _contacts.update { emptyList() } _contactsLoadVersion.update { 0L } + _contactsLoadCompletionVersion.update { 0L } clearPendingImport() _sessionRestorationFailed.update { false } _authState.update { PubkyAuthState.Idle } @@ -1417,6 +1423,10 @@ class PubkyRepo @Inject constructor( _contactsLoadVersion.update { it + 1 } } + private fun markContactsLoadCompleted() { + _contactsLoadCompletionVersion.update { it + 1 } + } + private suspend fun clearLocalState(publicPaykitCleanupPending: Boolean = false) = withContext(ioDispatcher) { runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index cea4609e64..f0173723f1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2294,7 +2294,7 @@ class AppViewModel @Inject constructor( val isInitializationReady = withTimeoutOrNull(PubkyService.AUTHORIZATION_TIMEOUT) { pubkyRepo.awaitInitialization() if (isContactLink && pubkyRepo.publicKey.value != null) { - pubkyRepo.contactsLoadVersion.first { it > 0 } + pubkyRepo.contactsLoadCompletionVersion.first { it > 0 } } true } ?: false diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 5b8d001cea..ef39309fef 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1412,6 +1412,25 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.isAuthenticated.value) } + @Test + fun `initialize should complete contacts load after contact fetch failure`() = test { + val session = "saved_session" + val unprefixedPublicKey = VALID_SELF_KEY.removePrefix("pubky") + val pubkyProfile = createPubkyProfile(name = "Restored User") + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + whenever(pubkyService.importSession(session)).thenReturn(unprefixedPublicKey) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) + whenever(pubkyService.contactRecords()).thenAnswer { throw TestAppError("Offline") } + + sut.initialize() + + assertEquals(1L, sut.contactsLoadCompletionVersion.value) + assertEquals(0L, sut.contactsLoadVersion.value) + assertTrue(sut.contacts.value.isEmpty()) + } + @Test fun `initialize should restore session from local secret key when saved session is missing`() = test { val secretKey = "local_secret" diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 725ad18b8a..240a85637b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -246,6 +246,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val pubkyPublicKey = MutableStateFlow(null) private val pubkyContacts = MutableStateFlow>(emptyList()) private val pubkyContactsLoadVersion = MutableStateFlow(0L) + private val pubkyContactsLoadCompletionVersion = MutableStateFlow(0L) private val pendingPaykitPaymentRequests = MutableStateFlow>(emptyList()) private val paykitPaymentRequestHistory = MutableStateFlow>(emptyList()) private val paykitSubscriptions = MutableStateFlow>(emptyList()) @@ -353,6 +354,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } .thenReturn(Result.success(Unit)) whenever(pubkyRepo.contactsLoadVersion).thenReturn(pubkyContactsLoadVersion) + whenever(pubkyRepo.contactsLoadCompletionVersion).thenReturn(pubkyContactsLoadCompletionVersion) whenever(paykitPaymentRequestRepo.pendingRequests).thenReturn(pendingPaykitPaymentRequests) whenever(paykitPaymentRequestRepo.paymentRequestHistory).thenReturn(paykitPaymentRequestHistory) whenever(paykitPaymentRequestRepo.subscriptions).thenReturn(paykitSubscriptions) @@ -2724,6 +2726,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyPublicKey.value = testPublicKey pubkyContactsLoadVersion.value = 1L + pubkyContactsLoadCompletionVersion.value = 1L sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) assertEquals(MainScreenEffect.Navigate(Routes.Profile), awaitItem()) } @@ -2749,6 +2752,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.resetIsAuthenticatedState() pubkyContacts.value = listOf(PubkyProfile.placeholder(testPublicKey)) pubkyContactsLoadVersion.value = 1L + pubkyContactsLoadCompletionVersion.value = 1L advanceUntilIdle() expectNoEvents() @@ -2761,6 +2765,22 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(coreService, never()).decode(any()) } + @Test + fun `contact deeplink continues after contacts load failure`() = test { + enablePaykitUi() + pubkyPublicKey.value = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + sut.mainScreenEffect.test { + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + runCurrent() + expectNoEvents() + + pubkyContactsLoadCompletionVersion.value = 1L + + assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem()) + } + verify(coreService, never()).decode(any()) + } + @Test fun `contact deeplink does not route when Paykit is disabled or wallet is missing`() = test { val intent = Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri()) diff --git a/journeys/README.md b/journeys/README.md index 59f8f03bc5..f8a26e7ba6 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -119,7 +119,7 @@ fixtures, push notifications) live in each suite's README. | [backup-restore](backup-restore) | 1 | VSS restore keeps tags and closed channels; wipes the wallet | | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [coin-selection](coin-selection) | 1 | Manual coin selection screen; needs 3+ on-chain UTXOs; no README | -| [deeplinks](deeplinks) | 3 | Pubky contact handoff, plus `bitkit://screen/…` and sheet routing behind the dev-mode gate | +| [deeplinks](deeplinks) | 3 | Pubky contact handoff, plus `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | | [home](home) | 1 | Pull to refresh on Home; checks the app log, no README | | [node-lifecycle](node-lifecycle) | 1 | Detached LDK restart completes; a cancelled RGS server change reconciles and recovers to Running; reads the app log; no README | From cc87440db60e5fe1ba8cdc9970c5168892d37e10 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 14:44:42 +0100 Subject: [PATCH 5/9] fix: retry contacts before deeplink routing --- .../java/to/bitkit/repositories/PubkyRepo.kt | 6 ++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 17 ++++++--- .../to/bitkit/repositories/PubkyRepoTest.kt | 24 ++++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 38 ++++++++++++++++++- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index cb940e6211..3b7ca12666 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -808,6 +808,7 @@ class PubkyRepo @Inject constructor( if (!loadContactsMutex.tryLock()) return _isLoadingContacts.update { true } + var shouldMarkLoadCompleted = false try { runSuspendCatching { withContext(ioDispatcher) { @@ -839,14 +840,15 @@ class PubkyRepo @Inject constructor( } _contacts.update { loadedContacts } markContactsLoaded() - markContactsLoadCompleted() + shouldMarkLoadCompleted = true }.onFailure { - if (_publicKey.value == pk) markContactsLoadCompleted() + shouldMarkLoadCompleted = _publicKey.value == pk Logger.error("Failed to load contacts", it, context = TAG) } } finally { _isLoadingContacts.update { false } loadContactsMutex.unlock() + if (shouldMarkLoadCompleted && _publicKey.value == pk) markContactsLoadCompleted() } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f0173723f1..358d92248b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2293,13 +2293,10 @@ class AppViewModel @Inject constructor( if (!PubkyAuthRequest.isSignupUrl(data)) { val isInitializationReady = withTimeoutOrNull(PubkyService.AUTHORIZATION_TIMEOUT) { pubkyRepo.awaitInitialization() - if (isContactLink && pubkyRepo.publicKey.value != null) { - pubkyRepo.contactsLoadCompletionVersion.first { it > 0 } - } - true + awaitContactDataForDeeplink(isContactLink) } ?: false if (!isInitializationReady) { - Logger.warn("Timed out waiting for Pubky initialization", context = TAG) + Logger.warn("Failed to initialize Pubky deeplink", context = TAG) ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString( @@ -2319,6 +2316,16 @@ class AppViewModel @Inject constructor( return isPaykitUiEnabledFromSettings() && walletRepo.walletExists() } + private suspend fun awaitContactDataForDeeplink(isContactLink: Boolean): Boolean { + if (!isContactLink || pubkyRepo.publicKey.value == null) return true + + pubkyRepo.contactsLoadCompletionVersion.first { it > 0 } + if (pubkyRepo.contactsLoadVersion.value > 0L) return true + + pubkyRepo.loadContacts() + return pubkyRepo.contactsLoadVersion.value > 0L + } + private suspend fun isPaykitUiEnabledFromSettings() = PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index ef39309fef..56b6634238 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -21,11 +21,15 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Test @@ -1739,6 +1743,26 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService, never()).contactRecords() } + @Test + fun `loadContacts should allow retry when failure completion is observed`() = test { + authenticateForTesting() + val completionVersion = sut.contactsLoadCompletionVersion.value + clearInvocations(pubkyService) + whenever(pubkyService.contactRecords()) + .thenAnswer { throw TestAppError("Offline") } + .thenReturn(emptyList()) + val retry = launch(Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { + sut.contactsLoadCompletionVersion.first { it > completionVersion } + sut.loadContacts() + } + + sut.loadContacts() + retry.join() + + verifyBlocking(pubkyService, times(2)) { contactRecords() } + assertEquals(completionVersion + 2, sut.contactsLoadCompletionVersion.value) + } + @Test fun `loadContacts should use placeholder when profile fetch fails`() = test { authenticateForTesting() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 240a85637b..3337ad0f0c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2766,9 +2766,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `contact deeplink continues after contacts load failure`() = test { + fun `contact deeplink retries contacts after initial load failure`() = test { enablePaykitUi() pubkyPublicKey.value = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + whenever(pubkyRepo.loadContacts()).thenAnswer { + pubkyContacts.value = listOf(PubkyProfile.placeholder(testPublicKey)) + pubkyContactsLoadVersion.value = 1L + } sut.mainScreenEffect.test { sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) runCurrent() @@ -2776,8 +2780,38 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pubkyContactsLoadCompletionVersion.value = 1L - assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem()) + assertEquals(MainScreenEffect.Navigate(Routes.ContactDetail(testPublicKey)), awaitItem()) + advanceUntilIdle() + } + verify(pubkyRepo).loadContacts() + verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(coreService, never()).decode(any()) + } + + @Test + fun `contact deeplink rejects when contacts retry fails`() = test { + enablePaykitUi() + pubkyPublicKey.value = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + whenever(context.getString(R.string.other__scan_err_decoding)).thenReturn("Decoding Error") + whenever(context.getString(R.string.other__scan__error__generic)).thenReturn("Unable to read data") + sut.mainScreenEffect.test { + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + runCurrent() + expectNoEvents() + + pubkyContactsLoadCompletionVersion.value = 1L + advanceUntilIdle() + + expectNoEvents() } + verify(pubkyRepo).loadContacts() + verify(toastManager).enqueue( + check { + assertEquals(Toast.ToastType.ERROR, it.type) + assertEquals("Decoding Error", it.title) + assertEquals("Unable to read data", it.description) + } + ) verify(coreService, never()).decode(any()) } From 456680c585563b7aa2015efb6df45ab0f4a3461a Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 22 Sep 2026 15:56:43 +0100 Subject: [PATCH 6/9] fix: wait for concurrent contact loads --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 358d92248b..333ea9a3a5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -2322,7 +2322,9 @@ class AppViewModel @Inject constructor( pubkyRepo.contactsLoadCompletionVersion.first { it > 0 } if (pubkyRepo.contactsLoadVersion.value > 0L) return true + val completionVersion = pubkyRepo.contactsLoadCompletionVersion.value pubkyRepo.loadContacts() + pubkyRepo.contactsLoadCompletionVersion.first { it > completionVersion } return pubkyRepo.contactsLoadVersion.value > 0L } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 3337ad0f0c..5e991b9cf7 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2730,6 +2730,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) assertEquals(MainScreenEffect.Navigate(Routes.Profile), awaitItem()) } + verify(pubkyRepo, never()).loadContacts() verify(coreService, never()).decode(any()) } @@ -2761,6 +2762,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() expectNoEvents() } + verify(pubkyRepo, never()).loadContacts() verify(refreshContactPaykitReceivers).invoke(testPublicKey) verify(coreService, never()).decode(any()) } @@ -2772,6 +2774,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(pubkyRepo.loadContacts()).thenAnswer { pubkyContacts.value = listOf(PubkyProfile.placeholder(testPublicKey)) pubkyContactsLoadVersion.value = 1L + pubkyContactsLoadCompletionVersion.value = 2L } sut.mainScreenEffect.test { sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) @@ -2788,10 +2791,40 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(coreService, never()).decode(any()) } + @Test + fun `contact deeplink waits for in-flight contacts retry`() = test { + enablePaykitUi() + pubkyPublicKey.value = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val retryAttempted = CompletableDeferred() + whenever(pubkyRepo.loadContacts()).thenAnswer { retryAttempted.complete(Unit) } + sut.mainScreenEffect.test { + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + runCurrent() + expectNoEvents() + + pubkyContactsLoadCompletionVersion.value = 1L + retryAttempted.await() + expectNoEvents() + + pubkyContacts.value = listOf(PubkyProfile.placeholder(testPublicKey)) + pubkyContactsLoadVersion.value = 1L + pubkyContactsLoadCompletionVersion.value = 2L + + assertEquals(MainScreenEffect.Navigate(Routes.ContactDetail(testPublicKey)), awaitItem()) + advanceUntilIdle() + } + verify(pubkyRepo).loadContacts() + verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(coreService, never()).decode(any()) + } + @Test fun `contact deeplink rejects when contacts retry fails`() = test { enablePaykitUi() pubkyPublicKey.value = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + whenever(pubkyRepo.loadContacts()).thenAnswer { + pubkyContactsLoadCompletionVersion.value = 2L + } whenever(context.getString(R.string.other__scan_err_decoding)).thenReturn("Decoding Error") whenever(context.getString(R.string.other__scan__error__generic)).thenReturn("Unable to read data") sut.mainScreenEffect.test { From 712fdf125a9a0d0026f5b12ebc8188e9f6ff919d Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 23 Sep 2026 12:56:25 +0100 Subject: [PATCH 7/9] docs: keep contact deeplink setup in journey --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index baa36f22ce..d89a536e3b 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,6 @@ This repository contains the **native Android app** for Bitkit. -## Contact deep links - -Use `bitkit://contact?pubky=` to open the same flow as scanning a Pubky key. -The key can be the raw 52-character public key or include its `pubky` prefix; URL-encode the value. -Unknown keys open Add Contact, saved contacts open Contact Detail, and your own key opens Profile. -This requires an existing wallet with Paykit enabled and respects the wallet's unlock flow. -Opening the link does not save a contact or initiate a payment. - ## Development ### Prerequisites From 285db622f668c7fdf6d4e4f67015efb1bfaa1894 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 12:54:15 +0100 Subject: [PATCH 8/9] fix: canonicalize pubky contact links --- .../java/to/bitkit/models/PubkyContactLink.kt | 14 +++++++++++++- .../java/to/bitkit/models/PubkyContactLinkTest.kt | 10 ++++++++-- .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 15 +++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyContactLink.kt b/app/src/main/java/to/bitkit/models/PubkyContactLink.kt index 98a10f4329..ca5bf5e94c 100644 --- a/app/src/main/java/to/bitkit/models/PubkyContactLink.kt +++ b/app/src/main/java/to/bitkit/models/PubkyContactLink.kt @@ -3,6 +3,12 @@ package to.bitkit.models import android.net.Uri object PubkyContactLink { + /** Z-base-32 characters ordered by their five-bit values. */ + private const val zBase32Alphabet = "ybndrfg8ejkmcpqxot1uwisza345h769" + + /** Mask for the only data bit in the final symbol of a 32-byte key. */ + private const val zBase32FinalSymbolDataMask = 0b10000 + fun matches(uri: Uri): Boolean = uri.scheme.equals("bitkit", ignoreCase = true) && uri.host.equals("contact", ignoreCase = true) @@ -17,6 +23,12 @@ object PubkyContactLink { val key = uri.getQueryParameters("pubky").singleOrNull() ?.takeIf { it.length <= PubkyPublicKeyFormat.maximumInputLength } ?: return null - return PubkyPublicKeyFormat.normalized(key) + return PubkyPublicKeyFormat.normalized(key)?.let(::canonicalize) + } + + private fun canonicalize(publicKey: String): String { + val lastCharacterValue = zBase32Alphabet.indexOf(publicKey.last()) + val canonicalLastCharacter = zBase32Alphabet[lastCharacterValue and zBase32FinalSymbolDataMask] + return publicKey.dropLast(1) + canonicalLastCharacter } } diff --git a/app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt b/app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt index 8550b241c8..5ea05b3150 100644 --- a/app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt @@ -11,11 +11,17 @@ import kotlin.test.assertNull @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class PubkyContactLinkTest { - private val key = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private val key = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + private val nonCanonicalKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" @Test fun `accepts raw prefixed and encoded keys`() { - listOf(key.removePrefix("pubky"), key, key.uppercase(), key.replace("pubky", "%70ubky")).forEach { value -> + listOf( + nonCanonicalKey.removePrefix("pubky"), + nonCanonicalKey, + nonCanonicalKey.uppercase(), + nonCanonicalKey.replace("pubky", "%70ubky"), + ).forEach { value -> assertEquals(key, PubkyContactLink.publicKey("bitkit://contact?pubky=$value".toUri())) } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index bdfd440f43..d913e8851d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -259,7 +259,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val paykitSubscriptions = MutableStateFlow>(emptyList()) private val onchainPaymentResolutions = MutableStateFlow>(emptyList()) private val surfacedPaykitPaymentRequestIds = mutableSetOf() - private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + private val nonCanonicalTestPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val signupAuthUrl = "pubkyring://signup?hs=homeserver&relay=https://relay&secret=request&caps=/pub/example/:rw" private val legacyAuthorizedSignupAuthUrl = signupAuthUrl.replace("pubkyring://", "pubkyauth://") @@ -2874,14 +2875,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { enablePaykitUi() advanceUntilIdle() sut.mainScreenEffect.test { - sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + sut.handleDeeplinkIntent( + Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$nonCanonicalTestPublicKey".toUri()), + ) assertEquals(MainScreenEffect.Navigate(Routes.AddContact(testPublicKey)), awaitItem()) advanceUntilIdle() pubkyPublicKey.value = testPublicKey pubkyContactsLoadVersion.value = 1L pubkyContactsLoadCompletionVersion.value = 1L - sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + sut.handleDeeplinkIntent( + Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$nonCanonicalTestPublicKey".toUri()), + ) assertEquals(MainScreenEffect.Navigate(Routes.Profile), awaitItem()) } verify(pubkyRepo, never()).loadContacts() @@ -2894,7 +2899,9 @@ class AppViewModelSendFlowTest : BaseUnitTest() { val initialized = CompletableDeferred() whenever(pubkyRepo.awaitInitialization()).doSuspendableAnswer { initialized.await() } sut.mainScreenEffect.test { - sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + sut.handleDeeplinkIntent( + Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$nonCanonicalTestPublicKey".toUri()), + ) runCurrent() expectNoEvents() From 0c774ea08f5698c0ff1bb7d5a463f6957801befa Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 24 Sep 2026 15:02:50 +0100 Subject: [PATCH 9/9] fix: canonicalize pubky contact inputs --- .../java/to/bitkit/models/PubkyContactLink.kt | 14 +----- .../to/bitkit/models/PubkyPublicKeyFormat.kt | 13 +++++ .../java/to/bitkit/repositories/PubkyRepo.kt | 16 +++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../bitkit/models/PubkyPublicKeyFormatTest.kt | 9 ++++ .../to/bitkit/repositories/PubkyRepoTest.kt | 50 +++++++++++++++++-- .../viewmodels/PubkyRouteResolverTest.kt | 11 ++-- 7 files changed, 91 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyContactLink.kt b/app/src/main/java/to/bitkit/models/PubkyContactLink.kt index ca5bf5e94c..8f08808b91 100644 --- a/app/src/main/java/to/bitkit/models/PubkyContactLink.kt +++ b/app/src/main/java/to/bitkit/models/PubkyContactLink.kt @@ -3,12 +3,6 @@ package to.bitkit.models import android.net.Uri object PubkyContactLink { - /** Z-base-32 characters ordered by their five-bit values. */ - private const val zBase32Alphabet = "ybndrfg8ejkmcpqxot1uwisza345h769" - - /** Mask for the only data bit in the final symbol of a 32-byte key. */ - private const val zBase32FinalSymbolDataMask = 0b10000 - fun matches(uri: Uri): Boolean = uri.scheme.equals("bitkit", ignoreCase = true) && uri.host.equals("contact", ignoreCase = true) @@ -23,12 +17,6 @@ object PubkyContactLink { val key = uri.getQueryParameters("pubky").singleOrNull() ?.takeIf { it.length <= PubkyPublicKeyFormat.maximumInputLength } ?: return null - return PubkyPublicKeyFormat.normalized(key)?.let(::canonicalize) - } - - private fun canonicalize(publicKey: String): String { - val lastCharacterValue = zBase32Alphabet.indexOf(publicKey.last()) - val canonicalLastCharacter = zBase32Alphabet[lastCharacterValue and zBase32FinalSymbolDataMask] - return publicKey.dropLast(1) + canonicalLastCharacter + return PubkyPublicKeyFormat.canonicalized(key) } } diff --git a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt index fc4b16ab06..b0d93ec62b 100644 --- a/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt +++ b/app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt @@ -5,6 +5,12 @@ import to.bitkit.ext.ellipsisMiddle import java.util.Locale object PubkyPublicKeyFormat { + /** Z-base-32 characters ordered by their five-bit values. */ + private const val zBase32Alphabet = "ybndrfg8ejkmcpqxot1uwisza345h769" + + /** Mask for the only data bit in the final symbol of a 32-byte key. */ + private const val zBase32FinalSymbolDataMask = 0b10000 + private const val displayEdgeLength = 4 private const val redactedLength = 16 const val maximumInputLength = 57 @@ -20,6 +26,13 @@ object PubkyPublicKeyFormat { return runCatching { PaykitPublicKeys.normalize(bounded(input)) }.getOrNull() } + fun canonicalized(input: String): String? { + val publicKey = normalized(input) ?: return null + val lastCharacterValue = zBase32Alphabet.indexOf(publicKey.last()) + val canonicalLastCharacter = zBase32Alphabet[lastCharacterValue and zBase32FinalSymbolDataMask] + return publicKey.dropLast(1) + canonicalLastCharacter + } + fun matches(lhs: String?, rhs: String?): Boolean { val normalizedLhs = lhs?.let(::normalized) ?: return false val normalizedRhs = rhs?.let(::normalized) ?: return false diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 3b7ca12666..8f1c052339 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -853,7 +853,7 @@ class PubkyRepo @Inject constructor( } suspend fun fetchContactProfile(publicKey: String): Result { - val prefixedKey = runCatching { requireAddableContactPublicKey(publicKey) } + val prefixedKey = runCatching { requireCanonicalAddableContactPublicKey(publicKey) } .getOrElse { return Result.failure(it) } return resolveContactProfile(prefixedKey) .map { it ?: PubkyProfile.placeholder(prefixedKey) } @@ -871,7 +871,7 @@ class PubkyRepo @Inject constructor( existingProfile: PubkyProfile? = null, ): Result = runSuspendCatching { withContext(ioDispatcher) { - val prefixedKey = requireAddableContactPublicKey( + val prefixedKey = requireCanonicalAddableContactPublicKey( publicKey = publicKey, allowExisting = existingProfile != null, ) @@ -1455,6 +1455,18 @@ class PubkyRepo @Inject constructor( private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String { val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey) + return requireValidAddableContactPublicKey(prefixedKey, allowExisting) + } + + private fun requireCanonicalAddableContactPublicKey( + publicKey: String, + allowExisting: Boolean = false, + ): String { + val prefixedKey = PubkyPublicKeyFormat.canonicalized(publicKey) + return requireValidAddableContactPublicKey(prefixedKey, allowExisting) + } + + private fun requireValidAddableContactPublicKey(prefixedKey: String?, allowExisting: Boolean): String { contactValidationError(prefixedKey, allowExisting)?.let { throw it } return checkNotNull(prefixedKey) { "Normalized pubky key is required" } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 4bc08f0dce..2e95a77e4e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -6035,7 +6035,7 @@ internal fun resolvePastedPubkyRoute( ): Routes? { if (!isPaykitEnabled) return null - val normalizedKey = PubkyPublicKeyFormat.normalized(input) ?: return null + val normalizedKey = PubkyPublicKeyFormat.canonicalized(input) ?: return null if (PubkyPublicKeyFormat.matches(normalizedKey, ownPublicKey)) { return Routes.Profile diff --git a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt index d8424a5106..2ae904a7a1 100644 --- a/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyPublicKeyFormatTest.kt @@ -38,6 +38,15 @@ class PubkyPublicKeyFormatTest { ) } + @Test + fun `canonicalized clears final padding bits`() { + val nonCanonicalKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + val canonicalKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + + assertEquals(canonicalKey, PubkyPublicKeyFormat.canonicalized(nonCanonicalKey)) + assertEquals(canonicalKey, PubkyPublicKeyFormat.canonicalized(canonicalKey)) + } + @Test fun `redacted shortens normalized pubky keys`() { val rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 56b6634238..541aaaf60e 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -72,9 +72,10 @@ import com.synonym.paykit.PubkyProfile as SdkPubkyProfile class PubkyRepoTest : BaseUnitTest() { companion object { // Valid 52-char z-base-32 key (+ "pubky" prefix = 57 chars) - private const val VALID_CONTACT_KEY_A = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - private const val VALID_CONTACT_KEY_B = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - private const val VALID_SELF_KEY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val VALID_CONTACT_KEY_A = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + private const val NON_CANONICAL_CONTACT_KEY_A = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val VALID_CONTACT_KEY_B = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + private const val VALID_SELF_KEY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" } private lateinit var sut: PubkyRepo @@ -1630,6 +1631,21 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals(listOf("new"), contacts.first().tags) } + @Test + fun `addContact should canonicalize key before persistence`() = test { + authenticateForTesting() + val profile = PubkyProfile.placeholder(NON_CANONICAL_CONTACT_KEY_A) + whenever { pubkyService.discoverRelevantReceiverPaths(VALID_CONTACT_KEY_A) }.thenReturn(emptyList()) + + val result = sut.addContact(NON_CANONICAL_CONTACT_KEY_A, existingProfile = profile) + + assertTrue(result.isSuccess) + assertEquals(VALID_CONTACT_KEY_A, sut.contacts.value.single().publicKey) + verifyBlocking(pubkyService) { + saveContact(VALID_CONTACT_KEY_A, profile.name, emptyList()) + } + } + @Test fun `refreshContactReceiverPaths should update saved contact receiver paths`() = test { authenticateForTesting() @@ -1659,6 +1675,34 @@ class PubkyRepoTest : BaseUnitTest() { } } + @Test + fun `refreshContactReceiverPaths should preserve a loaded noncanonical key`() = test { + authenticateForTesting() + whenever(pubkyService.contactRecords()).thenReturn( + listOf( + createContactRecord( + publicKey = NON_CANONICAL_CONTACT_KEY_A, + profile = createPaykitProfile("Alice"), + ), + ), + ) + sut.loadContacts() + clearInvocations(pubkyService) + whenever(pubkyService.discoverRelevantReceiverPaths(NON_CANONICAL_CONTACT_KEY_A)) + .thenReturn(listOf("bitkit/wallet", "bitkit/server")) + + val result = sut.refreshContactReceiverPaths(NON_CANONICAL_CONTACT_KEY_A) + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { + saveContact( + NON_CANONICAL_CONTACT_KEY_A, + "Alice", + listOf("bitkit/wallet", "bitkit/server"), + ) + } + } + @Test fun `loadProfile should ignore stale result when authenticated key changes`() = test { val oldSecret = "old_secret" diff --git a/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt b/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt index d7c444aac2..cc5ec2a3d7 100644 --- a/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/PubkyRouteResolverTest.kt @@ -8,8 +8,9 @@ import kotlin.test.assertNull class PubkyRouteResolverTest { companion object { - private const val VALID_PUBLIC_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - private const val OTHER_VALID_PUBLIC_KEY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val VALID_PUBLIC_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + private const val NON_CANONICAL_PUBLIC_KEY = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val OTHER_VALID_PUBLIC_KEY = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" } @Test @@ -17,7 +18,7 @@ class PubkyRouteResolverTest { assertEquals( Routes.Profile, resolvePastedPubkyRoute( - input = VALID_PUBLIC_KEY, + input = NON_CANONICAL_PUBLIC_KEY, ownPublicKey = VALID_PUBLIC_KEY, contacts = emptyList(), ), @@ -29,7 +30,7 @@ class PubkyRouteResolverTest { assertEquals( Routes.ContactDetail(VALID_PUBLIC_KEY), resolvePastedPubkyRoute( - input = VALID_PUBLIC_KEY, + input = NON_CANONICAL_PUBLIC_KEY, ownPublicKey = OTHER_VALID_PUBLIC_KEY, contacts = listOf(PubkyProfile.placeholder(VALID_PUBLIC_KEY)), ), @@ -41,7 +42,7 @@ class PubkyRouteResolverTest { assertEquals( Routes.AddContact(VALID_PUBLIC_KEY), resolvePastedPubkyRoute( - input = VALID_PUBLIC_KEY, + input = NON_CANONICAL_PUBLIC_KEY, ownPublicKey = OTHER_VALID_PUBLIC_KEY, contacts = emptyList(), ),