diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 38c2b4acaa..13af017fc4 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 @@ -209,6 +210,8 @@ 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 import to.bitkit.ui.utils.rememberIs24HourFormat @@ -301,6 +304,27 @@ fun ContentView( LaunchedEffect(Unit) { walletViewModel.handleHideBalanceOnOpen() } + val pendingScreenDeepLink by appViewModel.pendingScreenDeepLink.collectAsStateWithLifecycle() + + LaunchedEffect(pendingScreenDeepLink) { + val uri = pendingScreenDeepLink ?: return@LaunchedEffect + + 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) + if (!handled) { + Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView") + } + } + LaunchedEffect(appViewModel) { appViewModel.mainScreenEffect.collect { when (it) { @@ -964,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 0161dd331e..af9eddf270 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,7 +235,14 @@ class MainActivity : FragmentActivity() { return } + 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 new file mode 100644 index 0000000000..2472363835 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -0,0 +1,54 @@ +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.ExternalAmount::class, + Routes.ExternalConfirm::class, + Routes.ExternalSuccess::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 + + return kebabId(route) + } + + fun kebabId(route: KClass<*>): String? { + 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/SheetDeepLinks.kt b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt new file mode 100644 index 0000000000..27b4b567aa --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt @@ -0,0 +1,81 @@ +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.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.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/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..23846ec06c 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() @@ -3267,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) @@ -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..2c6d3cb47e --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -0,0 +1,143 @@ +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.ExternalAmount::class, + Routes.ExternalConfirm::class, + Routes.ExternalSuccess::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/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..6af88a626d --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt @@ -0,0 +1,138 @@ +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", + "backup/success", + "backup/warning", + ) + + 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", + "hardware/searching", + "hardware/paired", + ) + } + + @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/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/changelog.d/next/1119.added.md b/changelog.d/next/1119.added.md new file mode 100644 index 0000000000..78813b9206 --- /dev/null +++ b/changelog.d/next/1119.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..657400d3fb --- /dev/null +++ b/docs/deeplinks.md @@ -0,0 +1,121 @@ +## 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` 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 +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. + +### 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`, `multiple-devices`, `metadata` | +| `widgets` | `gallery` and every `*-preview` / `*-edit` route | +| `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 +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. + `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` + 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/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 + + 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 + +