From 54fce34bce4f7a09bc0e2e1381cb02b0ebccdb26 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Wed, 29 Jul 2026 17:51:50 +0300 Subject: [PATCH 1/8] feat: add deep links to every screen --- app/src/main/java/to/bitkit/ui/ContentView.kt | 17 +++ .../main/java/to/bitkit/ui/MainActivity.kt | 5 + .../to/bitkit/ui/utils/ScreenDeepLinks.kt | 47 ++++++ .../java/to/bitkit/ui/utils/Transitions.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 18 +++ .../to/bitkit/ui/utils/ScreenDeepLinksTest.kt | 140 ++++++++++++++++++ changelog.d/next/659.added.md | 1 + docs/deeplinks.md | 68 +++++++++ journeys/deeplinks/screen-deeplink.xml | 15 ++ 9 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt create mode 100644 app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt create mode 100644 changelog.d/next/659.added.md create mode 100644 docs/deeplinks.md create mode 100644 journeys/deeplinks/screen-deeplink.xml diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 38c2b4acaa..31b533f574 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -48,6 +48,7 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.appwidget.AppWidgetRefreshReason @@ -301,6 +302,22 @@ fun ContentView( LaunchedEffect(Unit) { walletViewModel.handleHideBalanceOnOpen() } + val pendingScreenDeepLink by appViewModel.pendingScreenDeepLink.collectAsStateWithLifecycle() + + LaunchedEffect(pendingScreenDeepLink) { + val uri = pendingScreenDeepLink ?: return@LaunchedEffect + + navController.currentBackStackEntryFlow.first() + appViewModel.consumeScreenDeepLink() + + val request = Intent(Intent.ACTION_VIEW, uri) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + val handled = navController.handleDeepLink(request) + if (!handled) { + Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView") + } + } + LaunchedEffect(appViewModel) { appViewModel.mainScreenEffect.collect { when (it) { diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 0161dd331e..0abc2618e3 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -53,6 +53,7 @@ import to.bitkit.ui.screens.SplashScreen import to.bitkit.ui.sheets.ForgotPinSheet import to.bitkit.ui.sheets.NewTransactionSheet import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.enableAppEdgeToEdge import to.bitkit.utils.Logger @@ -234,6 +235,10 @@ class MainActivity : FragmentActivity() { return } + // NavHost re-handles this intent when it sets its graph. Without CLEAR_TASK it restarts the + // whole task instead of navigating, costing an extra activity launch on every cold link. + intent.data?.let { if (ScreenDeepLinks.isScreenDeepLink(it)) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) } + appViewModel.handleDeeplinkIntent(intent) } diff --git a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt new file mode 100644 index 0000000000..395189671c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -0,0 +1,47 @@ +package to.bitkit.ui.utils + +import android.net.Uri +import androidx.navigation.NavDeepLink +import androidx.navigation.navDeepLink +import to.bitkit.ui.Routes +import kotlin.reflect.KClass + +object ScreenDeepLinks { + const val SCHEME = "bitkit" + const val HOST = "screen" + + private const val BASE_URI = "$SCHEME://$HOST" + + private val CAMEL_HUMP = Regex("(?<=[a-z0-9])(?=[A-Z])") + + private val DENIED: Set> = setOf( + Routes.AuthCheck::class, + Routes.CriticalUpdate::class, + Routes.LegacyRnRecovery::class, + Routes.LnurlChannel::class, + Routes.RecoveryMnemonic::class, + Routes.RecoveryMode::class, + ) + + fun isDenied(route: KClass<*>): Boolean = route in DENIED + + fun screenId(route: KClass<*>): String? { + if (!isScreenRoute(route)) return null + if (isDenied(route)) return null + + val name = route.simpleName ?: return null + return CAMEL_HUMP.split(name).joinToString("-") { it.lowercase() } + } + + fun basePath(route: KClass<*>): String? = screenId(route)?.let { "$BASE_URI/$it" } + + fun linksFor(route: KClass): List { + val basePath = basePath(route) ?: return emptyList() + return listOf(navDeepLink(route = route, basePath = basePath) {}) + } + + fun isScreenDeepLink(uri: Uri): Boolean = + uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST + + private fun isScreenRoute(route: KClass<*>): Boolean = Routes::class.java.isAssignableFrom(route.java) +} diff --git a/app/src/main/java/to/bitkit/ui/utils/Transitions.kt b/app/src/main/java/to/bitkit/ui/utils/Transitions.kt index d40cef1c11..45e4f13492 100644 --- a/app/src/main/java/to/bitkit/ui/utils/Transitions.kt +++ b/app/src/main/java/to/bitkit/ui/utils/Transitions.kt @@ -75,7 +75,7 @@ object Transitions { inline fun NavGraphBuilder.navigationWithDefaultTransitions( startDestination: Any, typeMap: Map> = emptyMap(), - deepLinks: List = emptyList(), + deepLinks: List = ScreenDeepLinks.linksFor(T::class), noinline enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = defaultEnterTrans, noinline exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition?)? = defaultExitTrans, noinline popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = defaultPopEnterTrans, @@ -100,7 +100,7 @@ inline fun NavGraphBuilder.navigationWithDefaultTransitions( @Suppress("LongParameterList", "MaxLineLength") inline fun NavGraphBuilder.composableWithDefaultTransitions( typeMap: Map> = emptyMap(), - deepLinks: List = emptyList(), + deepLinks: List = ScreenDeepLinks.linksFor(T::class), noinline enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = defaultEnterTrans, noinline exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition?)? = defaultExitTrans, noinline popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = defaultPopEnterTrans, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1c8c587538..94e7892ce4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -155,6 +155,7 @@ import to.bitkit.ui.shared.toast.ToastQueueManager import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -259,6 +260,9 @@ class AppViewModel @Inject constructor( private val _isAuthenticated = MutableStateFlow(false) val isAuthenticated = _isAuthenticated.asStateFlow() + private val _pendingScreenDeepLink = MutableStateFlow(null) + val pendingScreenDeepLink = _pendingScreenDeepLink.asStateFlow() + private val _showForgotPinSheet = MutableStateFlow(false) val showForgotPinSheet = _showForgotPinSheet.asStateFlow() @@ -3279,6 +3283,16 @@ class AppViewModel @Inject constructor( return@launch } + if (ScreenDeepLinks.isScreenDeepLink(uri)) { + if (!settingsStore.data.first().isDevModeEnabled) { + Logger.warn("Ignoring screen deeplink, dev mode is off", context = TAG) + return@launch + } + + _pendingScreenDeepLink.value = uri + return@launch + } + PubkyRingAuthCallback.parse(uri)?.let { if (!isPaykitEnabled.value) return@launch handlePubkyRingAuthCallback(it) @@ -3296,6 +3310,10 @@ class AppViewModel @Inject constructor( launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY) } + fun consumeScreenDeepLink() { + _pendingScreenDeepLink.value = null + } + private fun Uri.isRecoveryModeDeeplink(): Boolean { val normalizedScheme = scheme?.lowercase() if (normalizedScheme != BITKIT_SCHEME) return false diff --git a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt new file mode 100644 index 0000000000..1c75c3326f --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -0,0 +1,140 @@ +package to.bitkit.ui.utils + +import android.net.Uri +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.Routes +import to.bitkit.ui.sheets.SendRoute +import kotlin.reflect.KClass +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class ScreenDeepLinksTest : BaseUnitTest() { + private companion object { + val SENSITIVE_ROUTES: List> = listOf( + Routes.AuthCheck::class, + Routes.CriticalUpdate::class, + Routes.LegacyRnRecovery::class, + Routes.LnurlChannel::class, + Routes.RecoveryMnemonic::class, + Routes.RecoveryMode::class, + ) + } + + @Test + fun `screen id is derived from the route name in kebab-case`() { + val home = ScreenDeepLinks.screenId(Routes.Home::class) + val activityDetail = ScreenDeepLinks.screenId(Routes.ActivityDetail::class) + val rgsServer = ScreenDeepLinks.screenId(Routes.RgsServer::class) + + assertEquals("home", home) + assertEquals("activity-detail", activityDetail) + assertEquals("rgs-server", rgsServer) + } + + @Test + fun `routes without arguments produce a bare pattern`() { + val links = ScreenDeepLinks.linksFor(Routes.Settings::class) + + assertEquals(1, links.size) + assertEquals("bitkit://screen/settings", links.single().uriPattern) + } + + @Test + fun `required arguments are appended as path segments`() { + val links = ScreenDeepLinks.linksFor(Routes.ActivityAssignContact::class) + + assertEquals("bitkit://screen/activity-assign-contact/{id}", links.single().uriPattern) + } + + @Test + fun `a route with both argument kinds keeps the required one in the path`() { + val links = ScreenDeepLinks.linksFor(Routes.ActivityDetail::class) + + assertEquals("bitkit://screen/activity-detail/{id}?walletId={walletId}", links.single().uriPattern) + } + + @Test + fun `arguments with defaults are appended as query parameters`() { + val links = ScreenDeepLinks.linksFor(Routes.Contacts::class) + + assertEquals( + "bitkit://screen/contacts?showAddContactSheet={showAddContactSheet}", + links.single().uriPattern, + ) + } + + @Test + fun `sensitive routes are denied`() { + SENSITIVE_ROUTES.forEach { route -> + assertTrue(ScreenDeepLinks.isDenied(route), "expected ${route.simpleName} to be denied") + assertNull(ScreenDeepLinks.screenId(route)) + assertTrue(ScreenDeepLinks.linksFor(route).isEmpty()) + } + } + + @Test + fun `every route is either denied or reachable by a unique screen id`() { + val ids = mutableMapOf() + + Routes::class.sealedSubclasses.forEach { route -> + val name = route.simpleName.orEmpty() + val id = ScreenDeepLinks.screenId(route) + + if (ScreenDeepLinks.isDenied(route)) { + assertNull(id, "denied route $name must not expose a screen id") + return@forEach + } + + assertNotNull(id, "route $name has no screen id") + val clash = ids.put(id, name) + assertNull(clash, "screen id '$id' is used by both $clash and $name") + assertEquals(1, ScreenDeepLinks.linksFor(route).size, "route $name has no deep link") + } + } + + @Test + fun `denied set contains only routes that still exist`() { + val declared = Routes::class.sealedSubclasses.toSet() + val denied = SENSITIVE_ROUTES.filter { it in declared } + + assertEquals(SENSITIVE_ROUTES.size, denied.size) + } + + @Test + fun `sheet routes are not exposed as screen deeplinks`() { + val id = ScreenDeepLinks.screenId(SendRoute.Amount::class) + val links = ScreenDeepLinks.linksFor(SendRoute.Amount::class) + + assertNull(id) + assertTrue(links.isEmpty()) + } + + @Test + fun `screen deeplinks are recognised regardless of case`() { + val lowercase = ScreenDeepLinks.isScreenDeepLink(Uri.parse("bitkit://screen/settings")) + val uppercase = ScreenDeepLinks.isScreenDeepLink(Uri.parse("BITKIT://SCREEN/settings")) + + assertTrue(lowercase) + assertTrue(uppercase) + } + + @Test + fun `other bitkit hosts are not screen deeplinks`() { + val recoveryMode = ScreenDeepLinks.isScreenDeepLink(Uri.parse("bitkit://recovery-mode")) + val pubkyAuth = ScreenDeepLinks.isScreenDeepLink(Uri.parse("bitkit://pubky-auth/callback")) + val lightning = ScreenDeepLinks.isScreenDeepLink(Uri.parse("lightning:lnbc1")) + + assertFalse(recoveryMode) + assertFalse(pubkyAuth) + assertFalse(lightning) + } +} diff --git a/changelog.d/next/659.added.md b/changelog.d/next/659.added.md new file mode 100644 index 0000000000..78813b9206 --- /dev/null +++ b/changelog.d/next/659.added.md @@ -0,0 +1 @@ +Every screen can now be opened directly by a `bitkit://screen/...` link while dev mode is on, so QA and bug reports can jump straight to a screen instead of navigating to it. diff --git a/docs/deeplinks.md b/docs/deeplinks.md new file mode 100644 index 0000000000..63f55338ca --- /dev/null +++ b/docs/deeplinks.md @@ -0,0 +1,68 @@ +## Screen deep links + +Every screen in the root navigation graph is reachable by URI, so a flow can be entered directly +instead of tapped through. Intended for QA, agent journeys and bug repros. + +``` +bitkit://screen/[/...][?=] +``` + +Only available while dev mode is on (`Settings > Advanced > Dev Settings`), which is the default on +debug builds. + +### Screen ids + +The id is the route's class name in `Routes` (`ContentView.kt`) converted to kebab-case, so +`Routes.ActivityDetail` is `activity-detail` and `Routes.RgsServer` is `rgs-server`. Nothing +registers a screen by hand: `composableWithDefaultTransitions` derives the link from the route type, +so a new screen is reachable as soon as it is added to the graph. + +Route arguments become part of the pattern. Arguments without a default are path segments, +arguments with a default are query parameters. + +| Route | URI | +| - | - | +| `Settings` | `bitkit://screen/settings` | +| `ActivityAssignContact(id)` | `bitkit://screen/activity-assign-contact/{id}` | +| `ActivityDetail(id, walletId = null)` | `bitkit://screen/activity-detail/{id}?walletId={walletId}` | +| `ChannelDetail(channelId)` | `bitkit://screen/channel-detail/{channelId}` | +| `ShopWebView(page, title)` | `bitkit://screen/shop-web-view/{page}/{title}` | +| `Contacts(showAddContactSheet = false)` | `bitkit://screen/contacts?showAddContactSheet={showAddContactSheet}` | + +### Firing a link + +```sh +adb shell am start -W -a android.intent.action.VIEW \ + -d "bitkit://screen/settings" to.bitkit.dev +``` + +Escape `&` as `\&` when passing more than one query parameter. `-W` reports whether the intent +resolved, so a mistyped id fails loudly instead of silently opening the wallet overview. + +The app must already be past onboarding. A link that arrives earlier is held and replayed once the +wallet is loaded. A link that arrives while the PIN screen is up navigates behind it, so the PIN is +still required before anything is visible. + +Back from a deep-linked screen returns to the wallet overview rather than leaving the app. + +### Excluded screens + +Deep links are an input any installed app or web page can send, so they navigate and prefill only — +they never send, broadcast or change a setting without the usual confirmation. + +These screens are unreachable by URI and a link naming one is dropped with a warning: + +| Route | Reason | +| - | - | +| `RecoveryMnemonic` | displays the recovery phrase | +| `AuthCheck` | performs the action named by `onSuccessActionId`, including disabling the PIN | +| `RecoveryMode` | already has `bitkit://recovery-mode` | +| `LegacyRnRecovery` | migration recovery | +| `LnurlChannel` | carries an LNURL callback, scanner-driven only | +| `CriticalUpdate` | blocking screen | + +Payment URIs (`bitcoin:`, `lightning:`, `lnurl*`) are unaffected and still go through the scanner +decode path. + +Screens presented as bottom sheets (Send, Receive, Backup, Widgets) run their own nested graphs and +are not reachable yet. diff --git a/journeys/deeplinks/screen-deeplink.xml b/journeys/deeplinks/screen-deeplink.xml new file mode 100644 index 0000000000..b67c84f966 --- /dev/null +++ b/journeys/deeplinks/screen-deeplink.xml @@ -0,0 +1,15 @@ + + Precondition: onboarded dev wallet, dev mode on, at least one log file present. + + Run `adb shell am start -W -a android.intent.action.VIEW -d "bitkit://screen/settings" to.bitkit.dev` + Verify that the Settings screen is visible with the "General", "Security" and "Advanced" tabs + Press the device back button + Verify that the wallet overview is visible, not the launcher + Run `adb shell run-as to.bitkit.dev ls files/logs` and take the last filename + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/log-detail/<filename>" to.bitkit.dev` + Verify that the log detail screen is visible and its title matches that filename + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/recovery-mnemonic" to.bitkit.dev` + Verify that the screen does not change and the recovery phrase is never shown + Verify that logcat contains "Unhandled screen deeplink" for that URI + + From ec0907715d31042a7e249a014faa2bdc0052dbed Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Thu, 30 Jul 2026 10:20:44 +0300 Subject: [PATCH 2/8] refactor: drop explanatory comment --- app/src/main/java/to/bitkit/ui/MainActivity.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 0abc2618e3..d8e6241c63 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -235,8 +235,6 @@ class MainActivity : FragmentActivity() { return } - // NavHost re-handles this intent when it sets its graph. Without CLEAR_TASK it restarts the - // whole task instead of navigating, costing an extra activity launch on every cold link. intent.data?.let { if (ScreenDeepLinks.isScreenDeepLink(it)) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) } appViewModel.handleDeeplinkIntent(intent) From 1862da070b1ad5da9fc112ea84ada8623b0d45b7 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Thu, 30 Jul 2026 10:20:44 +0300 Subject: [PATCH 3/8] feat: add deep links to bottom sheet screens --- app/src/main/java/to/bitkit/ui/ContentView.kt | 6 + .../to/bitkit/ui/utils/ScreenDeepLinks.kt | 4 + .../java/to/bitkit/ui/utils/SheetDeepLinks.kt | 85 +++++++++++ .../to/bitkit/ui/utils/SheetDeepLinksTest.kt | 134 ++++++++++++++++++ docs/deeplinks.md | 46 +++++- journeys/deeplinks/sheet-deeplink.xml | 18 +++ 6 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt create mode 100644 app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt create mode 100644 journeys/deeplinks/sheet-deeplink.xml diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 31b533f574..3ec3afdfb7 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -210,6 +210,7 @@ import to.bitkit.ui.sheets.hardware.HardwareSheet import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.AutoReadClipboardHandler import to.bitkit.ui.utils.RequestNotificationPermissions +import to.bitkit.ui.utils.SheetDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.navigationWithDefaultTransitions import to.bitkit.ui.utils.rememberIs24HourFormat @@ -310,6 +311,11 @@ fun ContentView( navController.currentBackStackEntryFlow.first() appViewModel.consumeScreenDeepLink() + SheetDeepLinks.sheetFor(uri)?.let { + appViewModel.showSheet(it) + return@LaunchedEffect + } + val request = Intent(Intent.ACTION_VIEW, uri) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) val handled = navController.handleDeepLink(request) diff --git a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt index 395189671c..331ea1cbed 100644 --- a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -29,6 +29,10 @@ object ScreenDeepLinks { if (!isScreenRoute(route)) return null if (isDenied(route)) return null + return kebabId(route) + } + + fun kebabId(route: KClass<*>): String? { val name = route.simpleName ?: return null return CAMEL_HUMP.split(name).joinToString("-") { it.lowercase() } } diff --git a/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt new file mode 100644 index 0000000000..1c430e5976 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt @@ -0,0 +1,85 @@ +package to.bitkit.ui.utils + +import android.net.Uri +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.screens.wallets.receive.ReceiveRoute +import to.bitkit.ui.sheets.BackupRoute +import to.bitkit.ui.sheets.SendRoute +import to.bitkit.ui.sheets.WidgetsRoute +import to.bitkit.ui.sheets.hardware.HardwareRoute + +object SheetDeepLinks { + private val SHEETS: List = listOf( + Sheet.Send(SendRoute.Recipient), + Sheet.Send(SendRoute.Address), + Sheet.Send(SendRoute.ContactSelect), + Sheet.Send(SendRoute.Amount), + Sheet.Send(SendRoute.QrScanner), + Sheet.Send(SendRoute.CoinSelection), + Sheet.Send(SendRoute.AddTag), + Sheet.Send(SendRoute.ComingSoon), + Sheet.Send(SendRoute.Support), + + Sheet.Receive(ReceiveRoute.QR), + Sheet.Receive(ReceiveRoute.Amount), + Sheet.Receive(ReceiveRoute.EditInvoice), + Sheet.Receive(ReceiveRoute.AddTag), + Sheet.Receive(ReceiveRoute.GeoBlock), + + Sheet.Backup(BackupRoute.Intro), + Sheet.Backup(BackupRoute.Warning), + Sheet.Backup(BackupRoute.Success), + Sheet.Backup(BackupRoute.MultipleDevices), + Sheet.Backup(BackupRoute.Metadata), + + Sheet.Widgets(WidgetsRoute.Gallery), + Sheet.Widgets(WidgetsRoute.PricePreview), + Sheet.Widgets(WidgetsRoute.PriceEdit), + Sheet.Widgets(WidgetsRoute.WeatherPreview), + Sheet.Widgets(WidgetsRoute.WeatherEdit), + Sheet.Widgets(WidgetsRoute.BlocksPreview), + Sheet.Widgets(WidgetsRoute.BlocksEdit), + Sheet.Widgets(WidgetsRoute.HeadlinesPreview), + Sheet.Widgets(WidgetsRoute.HeadlinesEdit), + Sheet.Widgets(WidgetsRoute.FactsPreview), + Sheet.Widgets(WidgetsRoute.CalculatorPreview), + Sheet.Widgets(WidgetsRoute.SuggestionsPreview), + + Sheet.Hardware(HardwareRoute.Intro), + Sheet.Hardware(HardwareRoute.Searching), + Sheet.Hardware(HardwareRoute.Paired), + + Sheet.ActivityDateRangeSelector, + Sheet.ActivityTagSelector, + Sheet.QrScanner, + ) + + private val BY_PATH: Map = buildMap { + SHEETS.forEach { sheet -> + val sheetId = ScreenDeepLinks.kebabId(sheet::class) ?: return@forEach + putIfAbsent(sheetId, sheet) + + val route = routeOf(sheet) ?: return@forEach + val routeId = ScreenDeepLinks.kebabId(route::class) ?: return@forEach + put("$sheetId/$routeId", sheet) + } + } + + val paths: Set get() = BY_PATH.keys + + fun sheetFor(uri: Uri): Sheet? { + if (!ScreenDeepLinks.isScreenDeepLink(uri)) return null + + val path = uri.pathSegments.orEmpty().joinToString("/").lowercase() + return BY_PATH[path] + } + + private fun routeOf(sheet: Sheet): Any? = when (sheet) { + is Sheet.Send -> sheet.route + is Sheet.Receive -> sheet.route + is Sheet.Backup -> sheet.route + is Sheet.Widgets -> sheet.route + is Sheet.Hardware -> sheet.route + else -> null + } +} diff --git a/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt b/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt new file mode 100644 index 0000000000..5492967875 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt @@ -0,0 +1,134 @@ +package to.bitkit.ui.utils + +import android.net.Uri +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.screens.wallets.receive.ReceiveRoute +import to.bitkit.ui.sheets.BackupRoute +import to.bitkit.ui.sheets.SendRoute +import to.bitkit.ui.sheets.WidgetsRoute +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SheetDeepLinksTest : BaseUnitTest() { + private companion object { + const val KEBAB_SEGMENT = "[a-z0-9]+(-[a-z0-9]+)*" + val KEBAB_PATH = Regex("$KEBAB_SEGMENT(/$KEBAB_SEGMENT)?") + + val SENSITIVE_PATHS: List = listOf( + "backup/show-mnemonic", + "backup/show-passphrase", + "backup/confirm-mnemonic", + "backup/confirm-passphrase", + "pin", + "change-pin", + "disable-pin", + "force-transfer", + ) + + val UNSUPPORTED_START_PATHS: List = listOf( + "send/fee-nav", + "send/fee-rate", + "send/fee-custom", + "send/quick-pay", + "send/confirm", + "receive/confirm", + "receive/confirm-increase-inbound", + "receive/liquidity", + "receive/liquidity-additional", + ) + } + + @Test + fun `a bare sheet id opens the sheet at its first registered route`() { + val send = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/send")) + val receive = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/receive")) + val widgets = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/widgets")) + + assertEquals(Sheet.Send(SendRoute.Recipient), send) + assertEquals(Sheet.Receive(ReceiveRoute.QR), receive) + assertEquals(Sheet.Widgets(WidgetsRoute.Gallery), widgets) + } + + @Test + fun `backup opens at the intro rather than the sheet's own default route`() { + val backup = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/backup")) + + assertEquals(Sheet.Backup(BackupRoute.Intro), backup) + } + + @Test + fun `a sub-route segment selects the nested start destination`() { + val amount = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/send/amount")) + val priceEdit = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/widgets/price-edit")) + val editInvoice = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/receive/edit-invoice")) + + assertEquals(Sheet.Send(SendRoute.Amount), amount) + assertEquals(Sheet.Widgets(WidgetsRoute.PriceEdit), priceEdit) + assertEquals(Sheet.Receive(ReceiveRoute.EditInvoice), editInvoice) + } + + @Test + fun `sheets without a nested graph are reachable by id alone`() { + val tagSelector = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/activity-tag-selector")) + val qrScanner = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/qr-scanner")) + + assertEquals(Sheet.ActivityTagSelector, tagSelector) + assertEquals(Sheet.QrScanner, qrScanner) + } + + @Test + fun `sensitive sheets are unreachable`() { + SENSITIVE_PATHS.forEach { path -> + val sheet = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/$path")) + + assertNull(sheet, "expected '$path' to be unreachable") + } + } + + @Test + fun `routes that cannot be a nested start destination are unreachable`() { + UNSUPPORTED_START_PATHS.forEach { path -> + val sheet = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/$path")) + + assertNull(sheet, "expected '$path' to be unreachable") + } + } + + @Test + fun `an unknown sub-route does not fall back to the sheet default`() { + val sheet = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/send/not-a-route")) + + assertNull(sheet) + } + + @Test + fun `links outside the screen scheme are ignored`() { + val payment = SheetDeepLinks.sheetFor(Uri.parse("bitcoin://send")) + val otherHost = SheetDeepLinks.sheetFor(Uri.parse("bitkit://recovery-mode/send")) + + assertNull(payment) + assertNull(otherHost) + } + + @Test + fun `every registered path is a kebab-case id`() { + val malformed = SheetDeepLinks.paths.filterNot { it.matches(KEBAB_PATH) } + + assertTrue(malformed.isEmpty(), "malformed sheet paths: $malformed") + } + + @Test + fun `lookup ignores case`() { + val sheet = SheetDeepLinks.sheetFor(Uri.parse("bitkit://screen/Send/Coin-Selection")) + + assertEquals(Sheet.Send(SendRoute.CoinSelection), sheet) + } +} diff --git a/docs/deeplinks.md b/docs/deeplinks.md index 63f55338ca..8da6c3c809 100644 --- a/docs/deeplinks.md +++ b/docs/deeplinks.md @@ -7,7 +7,7 @@ instead of tapped through. Intended for QA, agent journeys and bug repros. bitkit://screen/[/...][?=] ``` -Only available while dev mode is on (`Settings > Advanced > Dev Settings`), which is the default on +Only available while dev mode is on (Settings ▸ Advanced ▸ Dev Settings), which is the default on debug builds. ### Screen ids @@ -47,7 +47,7 @@ Back from a deep-linked screen returns to the wallet overview rather than leavin ### Excluded screens -Deep links are an input any installed app or web page can send, so they navigate and prefill only — +Deep links are an input any installed app or web page can send, so they navigate and prefill only: they never send, broadcast or change a setting without the usual confirmation. These screens are unreachable by URI and a link naming one is dropped with a warning: @@ -64,5 +64,43 @@ These screens are unreachable by URI and a link naming one is dropped with a war Payment URIs (`bitcoin:`, `lightning:`, `lnurl*`) are unaffected and still go through the scanner decode path. -Screens presented as bottom sheets (Send, Receive, Backup, Widgets) run their own nested graphs and -are not reachable yet. +### Bottom sheets + +Sheets are not part of the root graph. They are `Sheet` values rendered by `SheetHost`, each +carrying the start route of its own nested graph. `SheetDeepLinks` maps a path to a `Sheet`, so the +URI shape is the same: + +``` +bitkit://screen/[/] +``` + +The bare id opens the sheet at its first registered route (`bitkit://screen/send` is the recipient +picker, `bitkit://screen/backup` the backup intro). + +| Sheet | Reachable routes | +| - | - | +| `send` | `recipient`, `address`, `contact-select`, `amount`, `qr-scanner`, `coin-selection`, `add-tag`, `coming-soon`, `support` | +| `receive` | `qr`, `amount`, `edit-invoice`, `add-tag`, `geo-block` | +| `backup` | `intro`, `warning`, `success`, `multiple-devices`, `metadata` | +| `widgets` | `gallery` and every `*-preview` / `*-edit` route | +| `hardware` | `intro`, `searching`, `paired` | +| `activity-date-range-selector`, `activity-tag-selector`, `qr-scanner` | no nested graph, id only | + +Unlike screens, sheet routes are registered by hand in `SheetDeepLinks`. A route is left out when it +cannot stand on its own: + +- **Children of a nested graph** cannot be a `NavHost` start destination. `SendRoute.FeeRate` and + `FeeCustom` live inside `navigationWithDefaultTransitions`, so starting there + throws `IllegalStateException: Cannot find startDestination ... from NavGraph`. `send/fee-nav` + itself resolves but renders only the screen title, so it is out too. +- **Routes that require state the flow built up.** `send/quick-pay` does + `requireNotNull(quickPayData)` (`SendSheet.kt:309`) and throws when entered cold. `send/confirm` + and the receive confirm/liquidity routes do not crash, but render an empty or zero-amount screen. + `send/confirm` offers a "Swipe To Pay" control over a payment that was never built. +- **Routes carrying flow-internal arguments** (`send/pending`, `hardware/pair-code`) are out. +- **Sensitive routes** follow the deny rules above: `backup/show-mnemonic`, `backup/show-passphrase` + and both confirm-mnemonic steps display or verify the recovery phrase, and the `pin`, `change-pin` + and `disable-pin` sheets are auth surfaces. `force-transfer` force-closes channels. + +An unregistered path is dropped with the same "Unhandled screen deeplink" warning as an unknown +screen, and it never falls back to the sheet's default route. diff --git a/journeys/deeplinks/sheet-deeplink.xml b/journeys/deeplinks/sheet-deeplink.xml new file mode 100644 index 0000000000..c59a3ef242 --- /dev/null +++ b/journeys/deeplinks/sheet-deeplink.xml @@ -0,0 +1,18 @@ + + Precondition: onboarded dev wallet, dev mode on, node connected. + + Run `adb shell am start -W -a android.intent.action.VIEW -d "bitkit://screen/send" to.bitkit.dev` + Verify that the Send sheet is visible on the recipient picker, showing "Scan QR", "Contact", "Paste Invoice" and "Enter Manually" + Press the device back button + Verify that the sheet is dismissed and the wallet overview is visible + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/widgets/price-edit" to.bitkit.dev` + Verify that the Bitcoin Price widget editor is visible with the "CURRENCY" and "TIMEFRAME" sections + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/backup" to.bitkit.dev` + Verify that the backup intro is visible, not the recovery phrase + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/backup/show-mnemonic" to.bitkit.dev` + Verify that the screen does not change and the recovery phrase is never shown + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/send/fee-rate" to.bitkit.dev` + Verify that the screen does not change and the app does not crash + Verify that logcat contains "Unhandled screen deeplink" for both rejected URIs + + From a31add750784558bcdd42276924bea69fe95fa0c Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Thu, 30 Jul 2026 20:48:49 +0300 Subject: [PATCH 4/8] chore: rename changelog fragment --- changelog.d/next/{659.added.md => 1119.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{659.added.md => 1119.added.md} (100%) diff --git a/changelog.d/next/659.added.md b/changelog.d/next/1119.added.md similarity index 100% rename from changelog.d/next/659.added.md rename to changelog.d/next/1119.added.md From 511250c900a53ffd17995c8940dffea7fa475fe3 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Thu, 30 Jul 2026 22:54:53 +0300 Subject: [PATCH 5/8] fix: deny sheet routes that commit flow state --- app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt | 4 ---- .../test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt | 4 ++++ docs/deeplinks.md | 10 ++++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt index 1c430e5976..27b4b567aa 100644 --- a/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt @@ -27,8 +27,6 @@ object SheetDeepLinks { Sheet.Receive(ReceiveRoute.GeoBlock), Sheet.Backup(BackupRoute.Intro), - Sheet.Backup(BackupRoute.Warning), - Sheet.Backup(BackupRoute.Success), Sheet.Backup(BackupRoute.MultipleDevices), Sheet.Backup(BackupRoute.Metadata), @@ -46,8 +44,6 @@ object SheetDeepLinks { Sheet.Widgets(WidgetsRoute.SuggestionsPreview), Sheet.Hardware(HardwareRoute.Intro), - Sheet.Hardware(HardwareRoute.Searching), - Sheet.Hardware(HardwareRoute.Paired), Sheet.ActivityDateRangeSelector, Sheet.ActivityTagSelector, diff --git a/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt b/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt index 5492967875..6af88a626d 100644 --- a/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt @@ -31,6 +31,8 @@ class SheetDeepLinksTest : BaseUnitTest() { "change-pin", "disable-pin", "force-transfer", + "backup/success", + "backup/warning", ) val UNSUPPORTED_START_PATHS: List = listOf( @@ -43,6 +45,8 @@ class SheetDeepLinksTest : BaseUnitTest() { "receive/confirm-increase-inbound", "receive/liquidity", "receive/liquidity-additional", + "hardware/searching", + "hardware/paired", ) } diff --git a/docs/deeplinks.md b/docs/deeplinks.md index 8da6c3c809..764a96f9fa 100644 --- a/docs/deeplinks.md +++ b/docs/deeplinks.md @@ -81,9 +81,9 @@ picker, `bitkit://screen/backup` the backup intro). | - | - | | `send` | `recipient`, `address`, `contact-select`, `amount`, `qr-scanner`, `coin-selection`, `add-tag`, `coming-soon`, `support` | | `receive` | `qr`, `amount`, `edit-invoice`, `add-tag`, `geo-block` | -| `backup` | `intro`, `warning`, `success`, `multiple-devices`, `metadata` | +| `backup` | `intro`, `multiple-devices`, `metadata` | | `widgets` | `gallery` and every `*-preview` / `*-edit` route | -| `hardware` | `intro`, `searching`, `paired` | +| `hardware` | `intro` | | `activity-date-range-selector`, `activity-tag-selector`, `qr-scanner` | no nested graph, id only | Unlike screens, sheet routes are registered by hand in `SheetDeepLinks`. A route is left out when it @@ -97,6 +97,12 @@ cannot stand on its own: `requireNotNull(quickPayData)` (`SendSheet.kt:309`) and throws when entered cold. `send/confirm` and the receive confirm/liquidity routes do not crash, but render an empty or zero-amount screen. `send/confirm` offers a "Swipe To Pay" control over a payment that was never built. + `hardware/searching` waits forever, because discovery is started by the intro's continue action + rather than by the screen. `hardware/paired` claims a paired device over default state. +- **Routes whose continue action commits the flow.** `backup/success` reports a backup that never + ran, and its OK button persists `backupVerified = true` + (`BackupNavSheetViewModel.onSuccessContinue`), which would mark a wallet backed up without the + recovery phrase ever being shown. `backup/warning` is one tap upstream of the same write. - **Routes carrying flow-internal arguments** (`send/pending`, `hardware/pair-code`) are out. - **Sensitive routes** follow the deny rules above: `backup/show-mnemonic`, `backup/show-passphrase` and both confirm-mnemonic steps display or verify the recovery phrase, and the `pin`, `change-pin` From 09239fbb0ba9cb8d7ec2f3e7b133ba6ed2ec7a65 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Fri, 31 Jul 2026 08:23:48 +0300 Subject: [PATCH 6/8] fix: gate cold-start links and deny external routes --- app/src/main/java/to/bitkit/ui/ContentView.kt | 3 +- .../main/java/to/bitkit/ui/MainActivity.kt | 7 +- .../to/bitkit/ui/utils/ScreenDeepLinks.kt | 3 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 20 +++--- .../to/bitkit/ui/utils/ScreenDeepLinksTest.kt | 3 + .../viewmodels/AppViewModelSendFlowTest.kt | 65 +++++++++++++++++++ docs/deeplinks.md | 13 +++- 7 files changed, 100 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 3ec3afdfb7..13af017fc4 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -210,6 +210,7 @@ import to.bitkit.ui.sheets.hardware.HardwareSheet import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.AutoReadClipboardHandler import to.bitkit.ui.utils.RequestNotificationPermissions +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.SheetDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.navigationWithDefaultTransitions @@ -987,7 +988,7 @@ private fun NavGraphBuilder.home( onConsumeHomeWidgetsPageRequest: () -> Unit, onCalculatorInputActiveChanged: (Boolean) -> Unit, ) { - composable { + composable(deepLinks = ScreenDeepLinks.linksFor(Routes.Home::class)) { val isRefreshing by walletViewModel.isRefreshing.collectAsStateWithLifecycle() val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle() val hazeState = rememberHazeState() diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index d8e6241c63..af9eddf270 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -235,9 +235,14 @@ class MainActivity : FragmentActivity() { return } - intent.data?.let { if (ScreenDeepLinks.isScreenDeepLink(it)) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) } + val isScreenLink = intent.data?.let { ScreenDeepLinks.isScreenDeepLink(it) } == true appViewModel.handleDeeplinkIntent(intent) + + if (isScreenLink) { + intent.data = null + setIntent(intent) + } } /** diff --git a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt index 331ea1cbed..2472363835 100644 --- a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -17,6 +17,9 @@ object ScreenDeepLinks { private val DENIED: Set> = setOf( Routes.AuthCheck::class, Routes.CriticalUpdate::class, + Routes.ExternalAmount::class, + Routes.ExternalConfirm::class, + Routes.ExternalSuccess::class, Routes.LegacyRnRecovery::class, Routes.LnurlChannel::class, Routes.RecoveryMnemonic::class, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 94e7892ce4..23846ec06c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3271,6 +3271,16 @@ class AppViewModel @Inject constructor( return@launch } + if (ScreenDeepLinks.isScreenDeepLink(uri)) { + if (!settingsStore.data.first().isDevModeEnabled) { + Logger.warn("Ignoring screen deeplink, dev mode is off", context = TAG) + return@launch + } + + _pendingScreenDeepLink.value = uri + return@launch + } + if (uri.isRecoveryModeDeeplink()) { lightningRepo.setRecoveryMode(enabled = true) delay(SCREEN_TRANSITION_DELAY) @@ -3283,16 +3293,6 @@ class AppViewModel @Inject constructor( return@launch } - if (ScreenDeepLinks.isScreenDeepLink(uri)) { - if (!settingsStore.data.first().isDevModeEnabled) { - Logger.warn("Ignoring screen deeplink, dev mode is off", context = TAG) - return@launch - } - - _pendingScreenDeepLink.value = uri - return@launch - } - PubkyRingAuthCallback.parse(uri)?.let { if (!isPaykitEnabled.value) return@launch handlePubkyRingAuthCallback(it) diff --git a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt index 1c75c3326f..2c6d3cb47e 100644 --- a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -22,6 +22,9 @@ class ScreenDeepLinksTest : BaseUnitTest() { val SENSITIVE_ROUTES: List> = listOf( Routes.AuthCheck::class, Routes.CriticalUpdate::class, + Routes.ExternalAmount::class, + Routes.ExternalConfirm::class, + Routes.ExternalSuccess::class, Routes.LegacyRnRecovery::class, Routes.LnurlChannel::class, Routes.RecoveryMnemonic::class, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index cd9976eac3..b52d0840ee 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -104,6 +104,7 @@ import java.net.URLEncoder import java.nio.charset.StandardCharsets import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -500,6 +501,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(coreService, never()).decode(any()) } + @Test + fun `screen namespace recovery-mode never enables recovery mode`() = test { + settingsData.value = SettingsData(isDevModeEnabled = true) + + sut.handleDeeplinkIntent(screenIntent("recovery-mode")) + advanceUntilIdle() + + verify(lightningRepo, never()).setRecoveryMode(true) + } + + @Test + fun `legacy recovery-mode deeplink still enables recovery mode`() = test { + sut.handleDeeplinkIntent(legacyRecoveryModeIntent()) + advanceUntilIdle() + + verify(lightningRepo).setRecoveryMode(true) + } + + @Test + fun `screen deeplink is dropped when dev mode is off`() = test { + settingsData.value = SettingsData(isDevModeEnabled = false) + + sut.handleDeeplinkIntent(screenIntent("settings")) + advanceUntilIdle() + + assertNull(sut.pendingScreenDeepLink.value) + } + + @Test + fun `screen deeplink is held for replay when dev mode is on`() = test { + settingsData.value = SettingsData(isDevModeEnabled = true) + + sut.handleDeeplinkIntent(screenIntent("settings")) + advanceUntilIdle() + + assertNotNull(sut.pendingScreenDeepLink.value) + } + @Test fun `public http Bitkit SamRock deeplink shows setup error without core decode`() = test { sut.handleDeeplinkIntent( @@ -1899,6 +1938,32 @@ class AppViewModelSendFlowTest : BaseUnitTest() { logDescription = "https://btcpay.example.com/plugins/store/samrock/protocol", ) + private fun screenIntent(vararg segments: String): Intent { + val uri = mock { + on { toString() }.thenReturn("bitkit://screen/${segments.joinToString("/")}") + on { scheme }.thenReturn("bitkit") + on { host }.thenReturn("screen") + on { pathSegments }.thenReturn(segments.toList()) + } + return mock { + on { action }.thenReturn(Intent.ACTION_VIEW) + on { data }.thenReturn(uri) + } + } + + private fun legacyRecoveryModeIntent(): Intent { + val uri = mock { + on { toString() }.thenReturn("bitkit://recovery-mode") + on { scheme }.thenReturn("bitkit") + on { host }.thenReturn("recovery-mode") + on { pathSegments }.thenReturn(emptyList()) + } + return mock { + on { action }.thenReturn(Intent.ACTION_VIEW) + on { data }.thenReturn(uri) + } + } + private fun samRockIntent(url: String): Intent { val uri = mock { on { toString() }.thenReturn(url) diff --git a/docs/deeplinks.md b/docs/deeplinks.md index 764a96f9fa..657400d3fb 100644 --- a/docs/deeplinks.md +++ b/docs/deeplinks.md @@ -36,8 +36,17 @@ adb shell am start -W -a android.intent.action.VIEW \ -d "bitkit://screen/settings" to.bitkit.dev ``` -Escape `&` as `\&` when passing more than one query parameter. `-W` reports whether the intent -resolved, so a mistyped id fails loudly instead of silently opening the wallet overview. +Escape `&` as `\&` when passing more than one query parameter. + +`-W` only reports that an activity resolved, which it always does: the manifest accepts every +`bitkit:` URI by scheme, so `bitkit://screen/typo` still starts `MainActivity` and is rejected later +in the app. To tell a good id from a bad one, assert on the destination, or watch for the rejection: + +```sh +adb logcat -c +adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/typo" to.bitkit.dev +adb logcat -d | grep "Unhandled screen deeplink" +``` The app must already be past onboarding. A link that arrives earlier is held and replayed once the wallet is loaded. A link that arrives while the PIN screen is up navigates behind it, so the PIN is From ad4ec80493f45054a0bfaf1be3cdf9dff8fc8fcf Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Fri, 31 Jul 2026 21:50:32 +0300 Subject: [PATCH 7/8] fix: deny late transfer routes and dismiss sheet on root link --- app/src/main/java/to/bitkit/ui/ContentView.kt | 8 +++++ .../main/java/to/bitkit/ui/MainActivity.kt | 5 +-- .../to/bitkit/ui/utils/ScreenDeepLinks.kt | 15 ++++++++ .../test/java/to/bitkit/ui/ContentViewTest.kt | 36 +++++++++++++++++++ .../to/bitkit/ui/utils/ScreenDeepLinksTest.kt | 6 ++++ docs/deeplinks.md | 6 ++++ journeys/deeplinks/screen-deeplink.xml | 9 +++++ 7 files changed, 81 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 13af017fc4..cce169fc8a 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -4,6 +4,7 @@ package to.bitkit.ui import android.Manifest import android.content.Intent +import android.net.Uri import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -317,6 +318,10 @@ fun ContentView( return@LaunchedEffect } + if (shouldDismissSheetForScreenLink(uri, appViewModel.currentSheet.value)) { + appViewModel.hideSheet() + } + val request = Intent(Intent.ACTION_VIEW, uri) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) val handled = navController.handleDeepLink(request) @@ -1895,6 +1900,9 @@ fun NavController.navigateToTransferSpendingStart( deviceId: String, ) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, deviceId)) +internal fun shouldDismissSheetForScreenLink(uri: Uri, currentSheet: Sheet?): Boolean = + currentSheet != null && SheetDeepLinks.sheetFor(uri) == null + internal fun transferEffectDestination(effect: TransferEffect): Routes? = when (effect) { TransferEffect.OnHwTxSigned -> Routes.SpendingHwSigned TransferEffect.OnSpendingFundingPaid -> Routes.SettingUp diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index af9eddf270..d3ad823396 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -235,12 +235,9 @@ class MainActivity : FragmentActivity() { return } - val isScreenLink = intent.data?.let { ScreenDeepLinks.isScreenDeepLink(it) } == true - appViewModel.handleDeeplinkIntent(intent) - if (isScreenLink) { - intent.data = null + if (ScreenDeepLinks.detachScreenUri(intent)) { setIntent(intent) } } diff --git a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt index 2472363835..8e056fc7e7 100644 --- a/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.utils +import android.content.Intent import android.net.Uri import androidx.navigation.NavDeepLink import androidx.navigation.navDeepLink @@ -24,6 +25,12 @@ object ScreenDeepLinks { Routes.LnurlChannel::class, Routes.RecoveryMnemonic::class, Routes.RecoveryMode::class, + Routes.SavingsProgress::class, + Routes.SettingUp::class, + Routes.SpendingAdvanced::class, + Routes.SpendingConfirm::class, + Routes.SpendingHwSign::class, + Routes.SpendingHwSigned::class, ) fun isDenied(route: KClass<*>): Boolean = route in DENIED @@ -50,5 +57,13 @@ object ScreenDeepLinks { fun isScreenDeepLink(uri: Uri): Boolean = uri.scheme?.lowercase() == SCHEME && uri.host?.lowercase() == HOST + fun detachScreenUri(intent: Intent): Boolean { + val uri = intent.data ?: return false + if (!isScreenDeepLink(uri)) return false + + intent.data = null + return true + } + private fun isScreenRoute(route: KClass<*>): Boolean = Routes::class.java.isAssignableFrom(route.java) } diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index 995c778e03..06388d5662 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -1,10 +1,19 @@ package to.bitkit.ui +import android.net.Uri import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.ui.components.Sheet import to.bitkit.viewmodels.TransferEffect import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) class ContentViewTest { @Test fun `spending start route uses intro until seen`() { @@ -26,4 +35,31 @@ class ContentViewTest { assertEquals(Routes.SpendingHwSigned, transferEffectDestination(TransferEffect.OnHwTxSigned)) assertNull(transferEffectDestination(TransferEffect.OnOrderCreated)) } + + @Test + fun `a root screen link dismisses an open sheet`() { + val uri = Uri.parse("bitkit://screen/settings") + + val result = shouldDismissSheetForScreenLink(uri, Sheet.Receive()) + + assertTrue(result) + } + + @Test + fun `a sheet link replaces the open sheet instead of dismissing it`() { + val uri = Uri.parse("bitkit://screen/send/amount") + + val result = shouldDismissSheetForScreenLink(uri, Sheet.Receive()) + + assertFalse(result) + } + + @Test + fun `a root screen link with no sheet open dismisses nothing`() { + val uri = Uri.parse("bitkit://screen/settings") + + val result = shouldDismissSheetForScreenLink(uri, null) + + assertFalse(result) + } } diff --git a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt index 2c6d3cb47e..dff3627f9a 100644 --- a/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -29,6 +29,12 @@ class ScreenDeepLinksTest : BaseUnitTest() { Routes.LnurlChannel::class, Routes.RecoveryMnemonic::class, Routes.RecoveryMode::class, + Routes.SavingsProgress::class, + Routes.SettingUp::class, + Routes.SpendingAdvanced::class, + Routes.SpendingConfirm::class, + Routes.SpendingHwSign::class, + Routes.SpendingHwSigned::class, ) } diff --git a/docs/deeplinks.md b/docs/deeplinks.md index 657400d3fb..7b2ff790c1 100644 --- a/docs/deeplinks.md +++ b/docs/deeplinks.md @@ -69,6 +69,12 @@ These screens are unreachable by URI and a link naming one is dropped with a war | `LegacyRnRecovery` | migration recovery | | `LnurlChannel` | carries an LNURL callback, scanner-driven only | | `CriticalUpdate` | blocking screen | +| `ExternalAmount`, `ExternalConfirm`, `ExternalSuccess` | a fresh `ExternalNav` scope has no peer, so confirm crashes on `requireNotNull` and success reports an open that never happened | +| `SavingsProgress`, `SettingUp`, `SpendingAdvanced`, `SpendingConfirm`, `SpendingHwSign`, `SpendingHwSigned` | read transfer state built by earlier steps; a fresh link renders empty or returns home, and `SavingsProgress` can act on default or retained channel state | + +Reaching the late transfer screens directly needs a per-flow setup contract that prepares the +expected state, does the server-side preparation, passes stable identifiers as route parameters and +reloads validated data before navigating. That is tracked separately. Payment URIs (`bitcoin:`, `lightning:`, `lnurl*`) are unaffected and still go through the scanner decode path. diff --git a/journeys/deeplinks/screen-deeplink.xml b/journeys/deeplinks/screen-deeplink.xml index b67c84f966..e3e71243dc 100644 --- a/journeys/deeplinks/screen-deeplink.xml +++ b/journeys/deeplinks/screen-deeplink.xml @@ -11,5 +11,14 @@ Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/recovery-mnemonic" to.bitkit.dev` Verify that the screen does not change and the recovery phrase is never shown Verify that logcat contains "Unhandled screen deeplink" for that URI + Run `adb shell am force-stop to.bitkit.dev` + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/settings" to.bitkit.dev` + Verify that the Settings screen is visible, so a cold start reaches it through the gated replay + Open Settings ▸ Support and tap the version row five times to turn dev mode off + Run `adb shell am force-stop to.bitkit.dev` + Run `adb shell am start -a android.intent.action.VIEW -d "bitkit://screen/settings" to.bitkit.dev` + Verify that the wallet overview is visible and Settings is not, on a cold start with dev mode off + Verify that logcat contains "Ignoring screen deeplink, dev mode is off" + Open Settings ▸ Support and tap the version row five times to turn dev mode back on From 1ff7743d2c2813b43c277745029b78e6f0ffbb21 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Fri, 31 Jul 2026 22:25:19 +0300 Subject: [PATCH 8/8] test: cover screen intent detachment at navhost boundary --- .../ui/utils/ScreenDeepLinkDetachmentTest.kt | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 app/src/androidTest/java/to/bitkit/ui/utils/ScreenDeepLinkDetachmentTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/utils/ScreenDeepLinkDetachmentTest.kt b/app/src/androidTest/java/to/bitkit/ui/utils/ScreenDeepLinkDetachmentTest.kt new file mode 100644 index 0000000000..0104c2ea79 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/utils/ScreenDeepLinkDetachmentTest.kt @@ -0,0 +1,137 @@ +package to.bitkit.ui.utils + +import android.content.Context +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.core.net.toUri +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.testing.TestNavHostController +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.Routes +import kotlin.reflect.KClass +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@ComposeUi +class ScreenDeepLinkDetachmentTest { + private companion object { + const val SETTINGS_URI = "bitkit://screen/settings" + const val DENIED_URI = "bitkit://screen/recovery-mnemonic" + } + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + private lateinit var navController: TestNavHostController + + @Test + fun testAttachedScreenUriIsHandledByGraphWithoutGate() { + withGraph(detach = false) { + assertTrue(isOn(Routes.Settings::class)) + } + } + + @Test + fun testGraphCreationStaysOnHomeAfterDetachment() { + withGraph(detach = true) { activity -> + assertNull(activity.intent.data) + assertTrue(isOn(Routes.Home::class)) + } + } + + @Test + fun testDetachedUriReachesSettingsOnlyThroughReplay() { + withGraph(detach = true) { activity -> + assertTrue(isOn(Routes.Home::class)) + + replay(activity, SETTINGS_URI) + + assertTrue(isOn(Routes.Settings::class)) + } + } + + @Test + fun testDeniedRouteIsNotMatchedByReplay() { + withGraph(detach = true) { activity -> + replay(activity, DENIED_URI) + + assertTrue(isOn(Routes.Home::class)) + } + } + + private fun withGraph(detach: Boolean, block: (ComponentActivity) -> Unit) { + val context = ApplicationProvider.getApplicationContext() + val launchIntent = Intent(context, ComponentActivity::class.java) + + ActivityScenario.launch(launchIntent).use { scenario -> + lateinit var activity: ComponentActivity + lateinit var launched: Intent + + scenario.onActivity { + activity = it + launched = it.intent + + val delivered = Intent(Intent.ACTION_VIEW, SETTINGS_URI.toUri()) + if (detach) { + ScreenDeepLinks.detachScreenUri(delivered) + } + it.intent = delivered + it.setContent { TestGraph() } + } + composeTestRule.waitForIdle() + + block(activity) + + scenario.onActivity { it.intent = launched } + } + } + + private fun replay(activity: ComponentActivity, uri: String) { + activity.runOnUiThread { + navController.handleDeepLink( + Intent(Intent.ACTION_VIEW, uri.toUri()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + ) + } + composeTestRule.waitForIdle() + } + + private fun isOn(route: KClass): Boolean = + navController.currentDestination?.hasRoute(route) == true + + @Composable + private fun TestGraph() { + val context = LocalContext.current + val controller = remember { + TestNavHostController(context).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + } + } + navController = controller + + NavHost(navController = controller, startDestination = Routes.Home) { + composable(deepLinks = ScreenDeepLinks.linksFor(Routes.Home::class)) { + Text("home") + } + composable(deepLinks = ScreenDeepLinks.linksFor(Routes.Settings::class)) { + Text("settings") + } + } + } +}