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..8f08808b91 --- /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.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 17817cdc95..8f1c052339 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() @@ -805,6 +808,7 @@ class PubkyRepo @Inject constructor( if (!loadContactsMutex.tryLock()) return _isLoadingContacts.update { true } + var shouldMarkLoadCompleted = false try { runSuspendCatching { withContext(ioDispatcher) { @@ -836,17 +840,20 @@ class PubkyRepo @Inject constructor( } _contacts.update { loadedContacts } markContactsLoaded() + shouldMarkLoadCompleted = true }.onFailure { + 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() } } 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) } @@ -864,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, ) @@ -1408,6 +1415,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 +1425,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) } @@ -1443,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 8c4581b76b..0a594e16f8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -120,6 +120,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 @@ -2359,20 +2360,31 @@ 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 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)) { val isInitializationReady = withTimeoutOrNull(PubkyService.AUTHORIZATION_TIMEOUT) { pubkyRepo.awaitInitialization() - 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(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 } @@ -2380,6 +2392,18 @@ 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 + + val completionVersion = pubkyRepo.contactsLoadCompletionVersion.value + pubkyRepo.loadContacts() + pubkyRepo.contactsLoadCompletionVersion.first { it > completionVersion } + return pubkyRepo.contactsLoadVersion.value > 0L + } + private suspend fun isPaykitUiEnabledFromSettings() = PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first()) @@ -2880,7 +2904,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() @@ -2941,12 +2976,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) { @@ -5579,6 +5614,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 @@ -5596,7 +5632,7 @@ class AppViewModel @Inject constructor( return@launch } - if (uri.isRecoveryModeDeeplink()) { + if (!isContactLink && uri.isRecoveryModeDeeplink()) { lightningRepo.setRecoveryMode(enabled = true) delay(SCREEN_TRANSITION_DELAY) mainScreenEffect( @@ -5626,7 +5662,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 = isContactLink, + ) } fun consumeScreenDeepLink() { @@ -6008,7 +6049,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/PubkyContactLinkTest.kt b/app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt new file mode 100644 index 0000000000..5ea05b3150 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt @@ -0,0 +1,51 @@ +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 = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy" + private val nonCanonicalKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + @Test + fun `accepts raw prefixed and encoded keys`() { + listOf( + nonCanonicalKey.removePrefix("pubky"), + nonCanonicalKey, + nonCanonicalKey.uppercase(), + nonCanonicalKey.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/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 5b8d001cea..541aaaf60e 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 @@ -68,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 @@ -1412,6 +1417,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" @@ -1607,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() @@ -1636,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" @@ -1720,6 +1787,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 148c33909b..42f9f3b8f8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -254,12 +254,14 @@ 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()) 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://") @@ -362,6 +364,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) @@ -2868,6 +2871,202 @@ 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=$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=$nonCanonicalTestPublicKey".toUri()), + ) + assertEquals(MainScreenEffect.Navigate(Routes.Profile), awaitItem()) + } + verify(pubkyRepo, never()).loadContacts() + 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=$nonCanonicalTestPublicKey".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 + pubkyContactsLoadCompletionVersion.value = 1L + advanceUntilIdle() + expectNoEvents() + + sut.setIsAuthenticated(true) + assertEquals(MainScreenEffect.Navigate(Routes.ContactDetail(testPublicKey)), awaitItem()) + advanceUntilIdle() + expectNoEvents() + } + verify(pubkyRepo, never()).loadContacts() + verify(refreshContactPaykitReceivers).invoke(testPublicKey) + verify(coreService, never()).decode(any()) + } + + @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 + pubkyContactsLoadCompletionVersion.value = 2L + } + sut.mainScreenEffect.test { + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, "bitkit://contact?pubky=$testPublicKey".toUri())) + runCurrent() + expectNoEvents() + + pubkyContactsLoadCompletionVersion.value = 1L + + assertEquals(MainScreenEffect.Navigate(Routes.ContactDetail(testPublicKey)), awaitItem()) + advanceUntilIdle() + } + verify(pubkyRepo).loadContacts() + verify(refreshContactPaykitReceivers).invoke(testPublicKey) + 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 { + 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()) + } + + @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 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 { + invalidLinks.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(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 fun `manual address input rejects pubky when Paykit UI is disabled`() = test { sut.setSendEvent(SendEvent.AddressChange(testPublicKey)) 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(), ), 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 fea768f346..b481acfc01 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -160,7 +160,8 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | | `onchain-receive/*` | port pending in synonymdev/bitkit-ios#588, which wires `onchainTransactionConfirmed` into the same received-sheet flow but carries no `journeys/` files. Two adaptations when it lands: iOS suppresses replayed historical receives with a `pendingRestoreActivitySeen` flag cleared by the first post-restore on-chain sync, not the one-hour block-timestamp guard used here, so a stale-confirmation step has to drive a restore instead of a clock; and iOS has no foreground-service path, so `confirmed-only-background-notification.xml` has no counterpart | | `backup/show-mnemonic-long-words.xml` | not ported — the long-word fit is an Android-only change (synonymdev/bitkit-android#633); whether iOS wraps long words at larger text sizes is unchecked | -| `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 | | `amount-limits/transfer-spending-preset-delete.xml` | not ported yet — the same fix shipped in synonymdev/bitkit-ios#289, so this one should port | diff --git a/journeys/deeplinks/pubky-contact.xml b/journeys/deeplinks/pubky-contact.xml new file mode 100644 index 0000000000..58f76cfbc2 --- /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. The pubky value accepts a raw 52-character key or the pubky-prefixed form and must be URL-encoded. 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 + +