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 000000000..0104c2ea7 --- /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") + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f79e60ff6..d93b2fa2a 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 @@ -208,7 +209,10 @@ 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.deepLinkableComposable import to.bitkit.ui.utils.navigationWithDefaultTransitions import to.bitkit.ui.utils.rememberIs24HourFormat import to.bitkit.ui.utils.rememberRequestNotificationPermission @@ -300,6 +304,31 @@ 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 (shouldDismissSheetForScreenLink(handled, appViewModel.currentSheet.value)) { + appViewModel.hideSheet() + } + if (!handled) { + Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView") + } + } + LaunchedEffect(appViewModel) { appViewModel.mainScreenEffect.collect { when (it) { @@ -713,7 +742,7 @@ private fun RootNavHost( navigationWithDefaultTransitions( startDestination = Routes.TransferIntro, ) { - composableWithDefaultTransitions { + deepLinkableComposable { TransferIntroScreen( onContinueClick = { navController.navigateToTransferFunding() @@ -722,7 +751,7 @@ private fun RootNavHost( onBackClick = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { SavingsIntroScreen( onContinueClick = { navController.navigateTo(Routes.SavingsAvailability) @@ -731,14 +760,14 @@ private fun RootNavHost( onBackClick = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { SavingsAvailabilityScreen( onBackClick = { navController.popBackStack() }, onCancelClick = { navController.navigateToHome() }, onContinueClick = { navController.navigateTo(Routes.SavingsConfirm) }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SavingsConfirmScreen( isOffline = connectivityState != ConnectivityState.CONNECTED, @@ -747,7 +776,7 @@ private fun RootNavHost( onBackClick = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { SavingsAdvancedScreen( onContinueClick = { navController.popBackStack(inclusive = false) }, onBackClick = { navController.popBackStack() }, @@ -762,7 +791,7 @@ private fun RootNavHost( onTransferUnavailable = { navController.popBackStack(inclusive = true) }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { SpendingIntroScreen( onContinueClick = { navController.navigateTo(Routes.SpendingAmount) @@ -771,7 +800,7 @@ private fun RootNavHost( onBackClick = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { entry -> + deepLinkableComposable { entry -> val deviceId = entry.toRoute().deviceId SpendingIntroScreen( onContinueClick = { @@ -781,7 +810,7 @@ private fun RootNavHost( onBackClick = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SpendingAmountScreen( viewModel = transferViewModel, @@ -798,7 +827,7 @@ private fun RootNavHost( }, ) } - composableWithDefaultTransitions { entry -> + deepLinkableComposable { entry -> val deviceId = entry.toRoute().deviceId val connectivityState by appViewModel.isOnline.collectAsStateWithLifecycle() SpendingAmountHwScreen( @@ -846,7 +875,7 @@ private fun RootNavHost( onOrderCreated = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { LiquidityScreen( onBackClick = { navController.popBackStack() }, onContinueClick = { navController.popBackStack() } @@ -860,7 +889,7 @@ private fun RootNavHost( } ) } - composableWithDefaultTransitions { + deepLinkableComposable { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() @@ -880,7 +909,7 @@ private fun RootNavHost( isGeoBlocked = isGeoBlocked, ) } - composableWithDefaultTransitions { + deepLinkableComposable { FundingAdvancedScreen( onLnurl = { appViewModel.showScannerSheet() }, onManual = { navController.navigateTo(Routes.ExternalNav) }, @@ -890,7 +919,7 @@ private fun RootNavHost( navigationWithDefaultTransitions( startDestination = ExternalConnection(), ) { - composableWithDefaultTransitions { + deepLinkableComposable { val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ExternalNav) } val route = it.toRoute() val viewModel = hiltViewModel(parentEntry) @@ -963,7 +992,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() @@ -998,7 +1027,7 @@ private fun NavGraphBuilder.home( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() val onchainActivities by activityListViewModel.onchainActivities.collectAsStateWithLifecycle() @@ -1017,7 +1046,7 @@ private fun NavGraphBuilder.home( forceCloseRemainingDuration = forceCloseRemainingDuration, ) } - composableWithDefaultTransitions { + deepLinkableComposable { val hasSeenSavingsIntro by settingsViewModel.hasSeenSavingsIntro.collectAsStateWithLifecycle() val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() @@ -1042,7 +1071,7 @@ private fun NavGraphBuilder.home( onBackClick = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { val deviceId = it.toRoute().deviceId val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() HardwareWalletScreen( @@ -1060,7 +1089,7 @@ private fun NavGraphBuilder.allActivity( activityListViewModel: ActivityListViewModel, navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { AllActivityScreen( viewModel = activityListViewModel, onBack = { navController.popBackStack() }, @@ -1073,12 +1102,12 @@ private fun NavGraphBuilder.settings( navController: NavHostController, settingsViewModel: SettingsViewModel, ) { - composableWithDefaultTransitions { + deepLinkableComposable { SettingsScreen(navController) } @Suppress("ForbiddenComment") // TODO: display as sheet - composableWithDefaultTransitions { + deepLinkableComposable { QuickPayIntroScreen( onBack = { navController.popBackStack() }, onContinue = { @@ -1087,36 +1116,36 @@ private fun NavGraphBuilder.settings( } ) } - composableWithDefaultTransitions { + deepLinkableComposable { QuickPaySettingsScreen( onBack = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { DevSettingsScreen(navController) } composableWithDefaultTransitions { LegacyRnRecoveryScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { TrezorScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { LdkDebugScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { VssDebugScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { ProbingToolScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { FeeSettingsScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { BlocktankRegtestScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { LanguageSettingsScreen( onBackClick = { navController.popBackStack() }, ) @@ -1153,7 +1182,7 @@ private fun NavGraphBuilder.contacts( settingsViewModel: SettingsViewModel, appViewModel: AppViewModel, ) { - composableWithDefaultTransitions { backStackEntry -> + deepLinkableComposable { backStackEntry -> PaykitRouteGuard( settingsViewModel = settingsViewModel, navController = navController, @@ -1182,7 +1211,7 @@ private fun NavGraphBuilder.contacts( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val isAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle() val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() @@ -1202,7 +1231,7 @@ private fun NavGraphBuilder.contacts( ) } } - composableWithDefaultTransitions { backStackEntry -> + deepLinkableComposable { backStackEntry -> PaykitRouteGuard(settingsViewModel, navController) { val route = backStackEntry.toRoute() val viewModel: ContactDetailViewModel = hiltViewModel() @@ -1221,7 +1250,7 @@ private fun NavGraphBuilder.contacts( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val viewModel: ContactActivityViewModel = hiltViewModel() ContactActivityScreen( @@ -1231,7 +1260,7 @@ private fun NavGraphBuilder.contacts( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val viewModel: AddContactViewModel = hiltViewModel() AddContactScreen( @@ -1251,7 +1280,7 @@ private fun NavGraphBuilder.contacts( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val viewModel: EditContactViewModel = hiltViewModel() EditContactScreen( @@ -1263,7 +1292,7 @@ private fun NavGraphBuilder.contacts( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val viewModel: ContactImportOverviewViewModel = hiltViewModel() ContactImportOverviewScreen( @@ -1276,7 +1305,7 @@ private fun NavGraphBuilder.contacts( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val viewModel: ContactImportSelectViewModel = hiltViewModel() ContactImportSelectScreen( @@ -1295,7 +1324,7 @@ private fun NavGraphBuilder.profile( navController: NavHostController, settingsViewModel: SettingsViewModel, ) { - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard( settingsViewModel = settingsViewModel, navController = navController, @@ -1315,7 +1344,7 @@ private fun NavGraphBuilder.profile( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { ProfileIntroScreen( onContinue = { @@ -1326,7 +1355,7 @@ private fun NavGraphBuilder.profile( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val viewModel: PubkyChoiceViewModel = hiltViewModel() PubkyChoiceScreen( @@ -1345,7 +1374,7 @@ private fun NavGraphBuilder.profile( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val viewModel: CreateProfileViewModel = hiltViewModel() CreateProfileScreen( @@ -1357,7 +1386,7 @@ private fun NavGraphBuilder.profile( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() val viewModel: EditProfileViewModel = hiltViewModel() @@ -1375,7 +1404,7 @@ private fun NavGraphBuilder.profile( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val viewModel: PayContactsViewModel = hiltViewModel() PayContactsScreen( @@ -1394,7 +1423,7 @@ private fun NavGraphBuilder.shop( settingsViewModel: SettingsViewModel, appViewModel: AppViewModel, ) { - composableWithDefaultTransitions { + deepLinkableComposable { ShopIntroScreen( onContinue = { settingsViewModel.setHasSeenShopIntro(true) @@ -1405,7 +1434,7 @@ private fun NavGraphBuilder.shop( } ) } - composableWithDefaultTransitions { + deepLinkableComposable { ShopDiscoverScreen( onBack = { navController.popBackStack() }, navigateWebView = { page, title -> @@ -1413,7 +1442,7 @@ private fun NavGraphBuilder.shop( } ) } - composableWithDefaultTransitions { + deepLinkableComposable { ShopWebViewScreen( onClose = { navController.navigateToHome() }, onBack = { navController.popBackStack() }, @@ -1431,25 +1460,25 @@ private fun NavGraphBuilder.generalSettingsSubScreens( appViewModel: AppViewModel, settingsViewModel: SettingsViewModel, ) { - composableWithDefaultTransitions { + deepLinkableComposable { WidgetsSettingsScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { TagsSettingsScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { HardwareWalletsSettingsScreen( navController = navController, onClickAdd = { appViewModel.showSheet(Sheet.Hardware()) }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { BackgroundPaymentsSettings( onBack = { navController.popBackStack() }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { val notificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> @@ -1469,40 +1498,41 @@ private fun NavGraphBuilder.generalSettingsSubScreens( } private fun NavGraphBuilder.advancedSettingsSubScreens(navController: NavHostController) { - composableWithDefaultTransitions { + deepLinkableComposable { CoinSelectPreferenceScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { ElectrumConfigScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { RgsServerScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { AddressTypePreferenceScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { AddressViewerScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { WatchOnlyAccountsScreen(navController) } - composableWithDefaultTransitions { + + deepLinkableComposable { NodeInfoScreen(navController) } } private fun NavGraphBuilder.transactionSpeedSettings(navController: NavHostController) { - composableWithDefaultTransitions { + deepLinkableComposable { TransactionSpeedSettingsScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { CustomFeeSettingsScreen(navController) } } private fun NavGraphBuilder.pinManagement(navController: NavHostController) { - composableWithDefaultTransitions { + deepLinkableComposable { PinManagementScreen(navController) } } @@ -1511,7 +1541,7 @@ private fun NavGraphBuilder.defaultUnitSettings( currencyViewModel: CurrencyViewModel, navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { DefaultUnitSettingsScreen(currencyViewModel, navController) } } @@ -1520,7 +1550,7 @@ private fun NavGraphBuilder.localCurrencySettings( currencyViewModel: CurrencyViewModel, navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { LocalCurrencySettingsScreen(currencyViewModel, navController) } } @@ -1528,7 +1558,7 @@ private fun NavGraphBuilder.localCurrencySettings( private fun NavGraphBuilder.backupSettings( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { BackupSettingsScreen(navController) } } @@ -1536,7 +1566,7 @@ private fun NavGraphBuilder.backupSettings( private fun NavGraphBuilder.resetAndRestoreSettings( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { ResetAndRestoreScreen(navController) } } @@ -1544,7 +1574,7 @@ private fun NavGraphBuilder.resetAndRestoreSettings( private fun NavGraphBuilder.channelOrdersSettings( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { ChannelOrdersScreen( onBackClick = { navController.popBackStack() }, onOrderItemClick = { navController.navigateToOrderDetail(it) }, @@ -1556,7 +1586,7 @@ private fun NavGraphBuilder.channelOrdersSettings( private fun NavGraphBuilder.orderDetailSettings( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { OrderDetailScreen( orderItem = it.toRoute(), onBackClick = { navController.popBackStack() }, @@ -1567,7 +1597,7 @@ private fun NavGraphBuilder.orderDetailSettings( private fun NavGraphBuilder.cjitDetailSettings( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { CJitDetailScreen( cjitItem = it.toRoute(), onBackClick = { navController.popBackStack() }, @@ -1578,7 +1608,7 @@ private fun NavGraphBuilder.cjitDetailSettings( private fun NavGraphBuilder.swapsSettings( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { SwapsScreen( onBackClick = { navController.popBackStack() }, onSwapItemClick = { navController.navigateToSwapDetail(it) }, @@ -1589,7 +1619,7 @@ private fun NavGraphBuilder.swapsSettings( private fun NavGraphBuilder.swapDetailSettings( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { SwapDetailScreen( swapItem = it.toRoute(), onBackClick = { navController.popBackStack() }, @@ -1603,19 +1633,19 @@ private fun NavGraphBuilder.lightningConnections( navigationWithDefaultTransitions( startDestination = Routes.LightningConnections, ) { - composableWithDefaultTransitions { + deepLinkableComposable { val parentEntry = remember(it) { navController.getBackStackEntry(Routes.ConnectionsNav) } val viewModel = hiltViewModel(parentEntry) LightningConnectionsScreen(navController, viewModel) } - composableWithDefaultTransitions { + deepLinkableComposable { val route = it.toRoute() ChannelDetailScreen( channelId = route.channelId, navController = navController, ) } - composableWithDefaultTransitions { + deepLinkableComposable { val route = it.toRoute() CloseConnectionScreen( channelId = route.channelId, @@ -1630,7 +1660,7 @@ private fun NavGraphBuilder.activityItem( navController: NavHostController, settingsViewModel: SettingsViewModel, ) { - composableWithDefaultTransitions { + deepLinkableComposable { val route = it.toRoute() ActivityDetailScreen( listViewModel = activityListViewModel, @@ -1644,7 +1674,7 @@ private fun NavGraphBuilder.activityItem( onCloseClick = { navController.navigateToHome() }, ) } - composableWithDefaultTransitions { + deepLinkableComposable { PaykitRouteGuard(settingsViewModel, navController) { val route = it.toRoute() ActivityAssignContactScreen( @@ -1653,7 +1683,7 @@ private fun NavGraphBuilder.activityItem( ) } } - composableWithDefaultTransitions { + deepLinkableComposable { ActivityExploreScreen( route = it.toRoute(), onBackClick = { navController.popBackStack() }, @@ -1676,10 +1706,10 @@ private fun NavGraphBuilder.authCheck( private fun NavGraphBuilder.logs( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { LogsScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { val route = it.toRoute() LogDetailScreen( navController = navController, @@ -1691,7 +1721,7 @@ private fun NavGraphBuilder.logs( private fun NavGraphBuilder.suggestions( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { BuyIntroScreen( onBackClick = { navController.popBackStack() } ) @@ -1728,15 +1758,15 @@ private fun NavGraphBuilder.recoveryMode( private fun NavGraphBuilder.support( navController: NavHostController, ) { - composableWithDefaultTransitions { + deepLinkableComposable { SupportScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { AppStatusScreen(navController) } - composableWithDefaultTransitions { + deepLinkableComposable { ReportIssueScreen( onBack = { navController.popBackStack() }, navigateResultScreen = { isSuccess -> @@ -1749,7 +1779,7 @@ private fun NavGraphBuilder.support( ) } - composableWithDefaultTransitions { + deepLinkableComposable { ReportIssueResultScreen( isSuccess = true, onBack = { navController.popBackStack() }, @@ -1757,7 +1787,7 @@ private fun NavGraphBuilder.support( ) } - composableWithDefaultTransitions { + deepLinkableComposable { ReportIssueResultScreen( isSuccess = false, onBack = { navController.popBackStack() }, @@ -1772,7 +1802,7 @@ private fun NavGraphBuilder.widgets( appViewModel: AppViewModel, onNavigateHomeWidgets: () -> Unit, ) { - composableWithDefaultTransitions { + deepLinkableComposable { val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() WidgetsIntroScreen( @@ -1876,6 +1906,9 @@ fun NavController.navigateToTransferSpendingStart( deviceId: String, ) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, deviceId)) +internal fun shouldDismissSheetForScreenLink(handled: Boolean, currentSheet: Sheet?): Boolean = + handled && currentSheet != null + internal fun transferEffectDestination(effect: TransferEffect): Routes? = when (effect) { TransferEffect.OnHwTxSigned -> Routes.SpendingHwSigned TransferEffect.OnSpendingFundingPaid -> Routes.SettingUp @@ -1928,59 +1961,63 @@ fun NavController.navigateToLanguageSettings() = navigateTo(Routes.LanguageSetti @Stable sealed interface Routes { + sealed interface DeepLinkable : Routes + + sealed interface InternalOnly : Routes + @Serializable - data object Home : Routes + data object Home : Routes.DeepLinkable @Serializable - data object Savings : Routes + data object Savings : Routes.DeepLinkable @Serializable - data object Spending : Routes + data object Spending : Routes.DeepLinkable @Serializable - data class HardwareWallet(val deviceId: String) : Routes + data class HardwareWallet(val deviceId: String) : Routes.DeepLinkable @Serializable - data object Settings : Routes + data object Settings : Routes.DeepLinkable @Serializable - data object NodeInfo : Routes + data object NodeInfo : Routes.DeepLinkable @Serializable - data object TransactionSpeedSettings : Routes + data object WidgetsSettings : Routes.DeepLinkable @Serializable - data object WidgetsSettings : Routes + data object TransactionSpeedSettings : Routes.DeepLinkable @Serializable - data object TagsSettings : Routes + data object TagsSettings : Routes.DeepLinkable @Serializable - data object HardwareWalletsSettings : Routes + data object HardwareWalletsSettings : Routes.DeepLinkable @Serializable - data object CoinSelectPreference : Routes + data object CoinSelectPreference : Routes.DeepLinkable @Serializable - data object ElectrumConfig : Routes + data object ElectrumConfig : Routes.DeepLinkable @Serializable - data object RgsServer : Routes + data object RgsServer : Routes.DeepLinkable @Serializable - data object AddressTypePreference : Routes + data object AddressTypePreference : Routes.DeepLinkable @Serializable - data object AddressViewer : Routes + data object WatchOnlyAccounts : Routes.DeepLinkable @Serializable - data object WatchOnlyAccounts : Routes + data object CustomFeeSettings : Routes.DeepLinkable @Serializable - data object CustomFeeSettings : Routes + data object AddressViewer : Routes.DeepLinkable @Serializable - data object PinManagement : Routes + data object PinManagement : Routes.DeepLinkable @Serializable data class AuthCheck( @@ -1988,260 +2025,260 @@ sealed interface Routes { val requirePin: Boolean = false, val requireBiometrics: Boolean = false, val onSuccessActionId: String, - ) : Routes + ) : Routes.InternalOnly @Serializable - data object DefaultUnitSettings : Routes + data object DefaultUnitSettings : Routes.DeepLinkable @Serializable - data object LocalCurrencySettings : Routes + data object LocalCurrencySettings : Routes.DeepLinkable @Serializable - data object BackupSettings : Routes + data object BackupSettings : Routes.DeepLinkable @Serializable - data object ResetAndRestoreSettings : Routes + data object ResetAndRestoreSettings : Routes.DeepLinkable @Serializable - data object ChannelOrdersSettings : Routes + data object ChannelOrdersSettings : Routes.DeepLinkable @Serializable - data object SwapsSettings : Routes + data object SwapsSettings : Routes.DeepLinkable @Serializable - data class SwapDetail(val id: String) : Routes + data class SwapDetail(val id: String) : Routes.DeepLinkable @Serializable - data object Logs : Routes + data object Logs : Routes.DeepLinkable @Serializable - data class LogDetail(val fileName: String) : Routes + data class LogDetail(val fileName: String) : Routes.DeepLinkable @Serializable - data class OrderDetail(val id: String) : Routes + data class OrderDetail(val id: String) : Routes.DeepLinkable @Serializable - data class CjitDetail(val id: String) : Routes + data class CjitDetail(val id: String) : Routes.DeepLinkable @Serializable - data object ConnectionsNav : Routes + data object ConnectionsNav : Routes.DeepLinkable @Serializable - data object LightningConnections : Routes + data object LightningConnections : Routes.DeepLinkable @Serializable - data class ChannelDetail(val channelId: String) : Routes + data class ChannelDetail(val channelId: String) : Routes.DeepLinkable @Serializable - data class CloseConnection(val channelId: String) : Routes + data class CloseConnection(val channelId: String) : Routes.DeepLinkable @Serializable - data object DevSettings : Routes + data object DevSettings : Routes.DeepLinkable @Serializable - data object LegacyRnRecovery : Routes + data object LegacyRnRecovery : Routes.InternalOnly @Serializable - data object LdkDebug : Routes + data object LdkDebug : Routes.DeepLinkable @Serializable - data object VssDebug : Routes + data object VssDebug : Routes.DeepLinkable @Serializable - data object ProbingTool : Routes + data object ProbingTool : Routes.DeepLinkable @Serializable - data object FeeSettings : Routes + data object FeeSettings : Routes.DeepLinkable @Serializable - data object RegtestSettings : Routes + data object RegtestSettings : Routes.DeepLinkable @Serializable - data object TransferRoot : Routes + data object TransferRoot : Routes.DeepLinkable @Serializable - data object TransferIntro : Routes + data object TransferIntro : Routes.DeepLinkable @Serializable - data object SpendingIntro : Routes + data object SpendingIntro : Routes.DeepLinkable @Serializable - data class SpendingIntroHw(val deviceId: String) : Routes + data class SpendingIntroHw(val deviceId: String) : Routes.DeepLinkable @Serializable - data object SpendingAmount : Routes + data object SpendingAmount : Routes.DeepLinkable @Serializable - data class SpendingAmountHw(val deviceId: String) : Routes + data class SpendingAmountHw(val deviceId: String) : Routes.DeepLinkable @Serializable - data class SpendingHwSign(val deviceId: String) : Routes + data class SpendingHwSign(val deviceId: String) : Routes.InternalOnly @Serializable - data object SpendingHwSigned : Routes + data object SpendingHwSigned : Routes.InternalOnly @Serializable - data object SpendingConfirm : Routes + data object SpendingConfirm : Routes.InternalOnly @Serializable - data object SpendingAdvanced : Routes + data object SpendingAdvanced : Routes.InternalOnly @Serializable - data object TransferLiquidity : Routes + data object TransferLiquidity : Routes.DeepLinkable @Serializable - data object SettingUp : Routes + data object SettingUp : Routes.InternalOnly @Serializable - data object SavingsIntro : Routes + data object SavingsIntro : Routes.DeepLinkable @Serializable - data object SavingsAvailability : Routes + data object SavingsAvailability : Routes.DeepLinkable @Serializable - data object SavingsConfirm : Routes + data object SavingsConfirm : Routes.DeepLinkable @Serializable - data object SavingsAdvanced : Routes + data object SavingsAdvanced : Routes.DeepLinkable @Serializable - data object SavingsProgress : Routes + data object SavingsProgress : Routes.InternalOnly @Serializable - data object Funding : Routes + data object Funding : Routes.DeepLinkable @Serializable - data object FundingAdvanced : Routes + data object FundingAdvanced : Routes.DeepLinkable @Serializable - data object ExternalNav : Routes + data object ExternalNav : Routes.DeepLinkable @Serializable - data class ExternalConnection(val scannedNodeUri: String? = null) : Routes + data class ExternalConnection(val scannedNodeUri: String? = null) : Routes.DeepLinkable @Serializable - data object ExternalAmount : Routes + data object ExternalAmount : Routes.InternalOnly @Serializable - data object ExternalConfirm : Routes + data object ExternalConfirm : Routes.InternalOnly @Serializable - data object ExternalSuccess : Routes + data object ExternalSuccess : Routes.InternalOnly @Serializable - data class LnurlChannel(val uri: String, val callback: String, val k1: String) : Routes + data class LnurlChannel(val uri: String, val callback: String, val k1: String) : Routes.InternalOnly @Serializable - data class ActivityDetail(val id: String, val walletId: String? = null) : Routes + data class ActivityDetail(val id: String, val walletId: String? = null) : Routes.DeepLinkable @Serializable - data class ActivityAssignContact(val id: String) : Routes + data class ActivityAssignContact(val id: String) : Routes.DeepLinkable @Serializable - data class ActivityExplore(val id: String, val walletId: String? = null) : Routes + data class ActivityExplore(val id: String, val walletId: String? = null) : Routes.DeepLinkable @Serializable - data object BuyIntro : Routes + data object BuyIntro : Routes.DeepLinkable @Serializable - data object Support : Routes + data object Support : Routes.DeepLinkable @Serializable - data object ReportIssue : Routes + data object ReportIssue : Routes.DeepLinkable @Serializable - data object ReportIssueSuccess : Routes + data object ReportIssueSuccess : Routes.DeepLinkable @Serializable - data object ReportIssueFailure : Routes + data object ReportIssueFailure : Routes.DeepLinkable @Serializable - data object QuickPayIntro : Routes + data object QuickPayIntro : Routes.DeepLinkable @Serializable - data object QuickPaySettings : Routes + data object QuickPaySettings : Routes.DeepLinkable @Serializable - data object LanguageSettings : Routes + data object LanguageSettings : Routes.DeepLinkable @Serializable - data class Contacts(val showAddContactSheet: Boolean = false) : Routes + data class Contacts(val showAddContactSheet: Boolean = false) : Routes.DeepLinkable @Serializable - data object ContactsIntro : Routes + data object ContactsIntro : Routes.DeepLinkable @Serializable data class ContactDetail( val publicKey: String, val showDeleteAction: Boolean = false, - ) : Routes + ) : Routes.DeepLinkable @Serializable - data class ContactActivity(val publicKey: String) : Routes + data class ContactActivity(val publicKey: String) : Routes.DeepLinkable @Serializable - data object Profile : Routes + data object Profile : Routes.DeepLinkable @Serializable - data object ProfileIntro : Routes + data object ProfileIntro : Routes.DeepLinkable @Serializable - data object PubkyChoice : Routes + data object PubkyChoice : Routes.DeepLinkable @Serializable - data object CreateProfile : Routes + data object CreateProfile : Routes.DeepLinkable @Serializable - data object EditProfile : Routes + data object EditProfile : Routes.DeepLinkable @Serializable - data object PayContacts : Routes + data object PayContacts : Routes.DeepLinkable @Serializable - data class AddContact(val publicKey: String) : Routes + data class AddContact(val publicKey: String) : Routes.DeepLinkable @Serializable - data class EditContact(val publicKey: String) : Routes + data class EditContact(val publicKey: String) : Routes.DeepLinkable @Serializable - data object ContactImportOverview : Routes + data object ContactImportOverview : Routes.DeepLinkable @Serializable - data object ContactImportSelect : Routes + data object ContactImportSelect : Routes.DeepLinkable @Serializable - data object ShopIntro : Routes + data object ShopIntro : Routes.DeepLinkable @Serializable - data object ShopDiscover : Routes + data object ShopDiscover : Routes.DeepLinkable @Serializable - data class ShopWebView(val page: String, val title: String) : Routes + data class ShopWebView(val page: String, val title: String) : Routes.DeepLinkable @Serializable - data object WidgetsIntro : Routes + data object WidgetsIntro : Routes.DeepLinkable @Serializable - data object AppStatus : Routes + data object AppStatus : Routes.DeepLinkable @Serializable - data object CriticalUpdate : Routes + data object CriticalUpdate : Routes.InternalOnly @Serializable - data object RecoveryMode : Routes + data object RecoveryMode : Routes.InternalOnly @Serializable - data object RecoveryMnemonic : Routes + data object RecoveryMnemonic : Routes.InternalOnly @Serializable - data object BackgroundPaymentsIntro : Routes + data object BackgroundPaymentsIntro : Routes.DeepLinkable @Serializable - data object BackgroundPaymentsSettings : Routes + data object BackgroundPaymentsSettings : Routes.DeepLinkable @Serializable - data object AllActivity : Routes + data object AllActivity : Routes.DeepLinkable @Serializable - data object Trezor : Routes + data object Trezor : Routes.DeepLinkable } diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 0161dd331..d3ad82339 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 @@ -235,6 +236,10 @@ class MainActivity : FragmentActivity() { } appViewModel.handleDeeplinkIntent(intent) + + if (ScreenDeepLinks.detachScreenUri(intent)) { + setIntent(intent) + } } /** diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt index 75a330420..e4a4bcc76 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt @@ -30,6 +30,7 @@ import to.bitkit.ui.navigateTo import to.bitkit.ui.openNotificationSettings import to.bitkit.ui.screens.wallets.send.AddTagScreen import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.rememberNotificationToggleClick import to.bitkit.ui.walletViewModel @@ -230,30 +231,47 @@ fun ReceiveSheet( } sealed interface ReceiveRoute { + sealed interface DeepLinkStart : ReceiveRoute + + sealed interface InternalOnly : ReceiveRoute + @Serializable - data object QR : ReceiveRoute + data object QR : DeepLinkStart @Serializable - data object Amount : ReceiveRoute + data object Amount : DeepLinkStart @Serializable - data object Confirm : ReceiveRoute + data object Confirm : InternalOnly @Serializable - data object ConfirmIncreaseInbound : ReceiveRoute + data object ConfirmIncreaseInbound : InternalOnly @Serializable - data object Liquidity : ReceiveRoute + data object Liquidity : InternalOnly @Serializable - data object LiquidityAdditional : ReceiveRoute + data object LiquidityAdditional : InternalOnly @Serializable - data object EditInvoice : ReceiveRoute + data object EditInvoice : DeepLinkStart @Serializable - data object AddTag : ReceiveRoute + data object AddTag : DeepLinkStart @Serializable - data object GeoBlock : ReceiveRoute + data object GeoBlock : DeepLinkStart + + companion object { + private val DEEP_LINK_STARTS: List = listOf( + QR, + Amount, + EditInvoice, + AddTag, + GeoBlock, + ) + + fun fromDeepLink(path: String): DeepLinkStart? = + ScreenDeepLinks.matchStart(path, QR, DEEP_LINK_STARTS) + } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/BackupSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BackupSheet.kt index 2598137cf..8f107e831 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BackupSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BackupSheet.kt @@ -30,6 +30,7 @@ import to.bitkit.ui.settings.backups.ShowPassphraseScreen import to.bitkit.ui.settings.backups.SuccessScreen import to.bitkit.ui.settings.backups.WarningScreen import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions @Composable @@ -155,30 +156,45 @@ fun BackupSheet( } sealed interface BackupRoute { + sealed interface DeepLinkStart : BackupRoute + + sealed interface InternalOnly : BackupRoute + @Serializable - data object Intro : BackupRoute + data object Intro : DeepLinkStart @Serializable - data object ShowMnemonic : BackupRoute + data object ShowMnemonic : InternalOnly @Serializable - data object ShowPassphrase : BackupRoute + data object ShowPassphrase : InternalOnly @Serializable - data object ConfirmMnemonic : BackupRoute + data object ConfirmMnemonic : InternalOnly @Serializable - data object ConfirmPassphrase : BackupRoute + data object ConfirmPassphrase : InternalOnly @Serializable - data object Warning : BackupRoute + data object Warning : InternalOnly @Serializable - data object Success : BackupRoute + data object Success : InternalOnly @Serializable - data object MultipleDevices : BackupRoute + data object MultipleDevices : DeepLinkStart @Serializable - data object Metadata : BackupRoute + data object Metadata : DeepLinkStart + + companion object { + private val DEEP_LINK_STARTS: List = listOf( + Intro, + MultipleDevices, + Metadata, + ) + + fun fromDeepLink(path: String): DeepLinkStart? = + ScreenDeepLinks.matchStart(path, Intro, DEEP_LINK_STARTS) + } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 1f54c4442..b64a280c9 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -54,6 +54,7 @@ import to.bitkit.ui.screens.wallets.withdraw.WithdrawErrorScreen import to.bitkit.ui.settings.support.SupportScreen import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.ui.utils.navigationWithDefaultTransitions import to.bitkit.viewmodels.AppViewModel @@ -401,63 +402,84 @@ fun SendSheet( } sealed interface SendRoute { + sealed interface DeepLinkStart : SendRoute + + sealed interface InternalOnly : SendRoute + @Serializable - data object Recipient : SendRoute + data object Recipient : DeepLinkStart @Serializable - data object Address : SendRoute + data object Address : DeepLinkStart @Serializable - data object ContactSelect : SendRoute + data object ContactSelect : DeepLinkStart @Serializable - data object Amount : SendRoute + data object Amount : DeepLinkStart @Serializable - data object QrScanner : SendRoute + data object QrScanner : DeepLinkStart @Serializable - data object WithdrawConfirm : SendRoute + data object WithdrawConfirm : InternalOnly @Serializable - data object WithdrawError : SendRoute + data object WithdrawError : InternalOnly @Serializable - data object Support : SendRoute + data object Support : DeepLinkStart @Serializable - data object AddTag : SendRoute + data object AddTag : DeepLinkStart @Serializable - data object PinCheck : SendRoute + data object PinCheck : InternalOnly @Serializable - data object CoinSelection : SendRoute + data object CoinSelection : DeepLinkStart @Serializable - data object QuickPay : SendRoute + data object QuickPay : InternalOnly @Serializable - data object FeeNav : SendRoute + data object FeeNav : InternalOnly @Serializable - data object FeeRate : SendRoute + data object FeeRate : InternalOnly @Serializable - data object FeeCustom : SendRoute + data object FeeCustom : InternalOnly @Serializable - data object Confirm : SendRoute + data object Confirm : InternalOnly @Serializable - data object Success : SendRoute + data object Success : InternalOnly @Serializable - data object ComingSoon : SendRoute + data object ComingSoon : DeepLinkStart @Serializable - data class Pending(val paymentHash: String, val amount: Long) : SendRoute + data class Pending(val paymentHash: String, val amount: Long) : InternalOnly @Serializable - data class Error(val message: String? = null) : SendRoute + data class Error(val message: String? = null) : InternalOnly + + companion object { + private val DEEP_LINK_STARTS: List = listOf( + Recipient, + Address, + ContactSelect, + Amount, + QrScanner, + CoinSelection, + AddTag, + ComingSoon, + Support, + ) + + fun fromDeepLink(path: String): DeepLinkStart? = + ScreenDeepLinks.matchStart(path, Recipient, DEEP_LINK_STARTS) + } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt index 150d3915b..888ec4e1b 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt @@ -55,6 +55,7 @@ import to.bitkit.ui.screens.widgets.weather.WeatherPreviewScreen import to.bitkit.ui.screens.widgets.weather.WeatherViewModel import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.viewmodels.AppViewModel @@ -404,41 +405,65 @@ private enum class WidgetFlowKey { } sealed interface WidgetsRoute { + sealed interface DeepLinkStart : WidgetsRoute + + sealed interface InternalOnly : WidgetsRoute + @Serializable - data object Gallery : WidgetsRoute + data object Gallery : DeepLinkStart @Serializable - data object PricePreview : WidgetsRoute + data object PricePreview : DeepLinkStart @Serializable - data object PriceEdit : WidgetsRoute + data object PriceEdit : DeepLinkStart @Serializable - data object WeatherPreview : WidgetsRoute + data object WeatherPreview : DeepLinkStart @Serializable - data object WeatherEdit : WidgetsRoute + data object WeatherEdit : DeepLinkStart @Serializable - data object BlocksPreview : WidgetsRoute + data object BlocksPreview : DeepLinkStart @Serializable - data object BlocksEdit : WidgetsRoute + data object BlocksEdit : DeepLinkStart @Serializable - data object HeadlinesPreview : WidgetsRoute + data object HeadlinesPreview : DeepLinkStart @Serializable - data object HeadlinesEdit : WidgetsRoute + data object HeadlinesEdit : DeepLinkStart @Serializable - data object FactsPreview : WidgetsRoute + data object FactsPreview : DeepLinkStart @Serializable - data object CalculatorPreview : WidgetsRoute + data object CalculatorPreview : DeepLinkStart @Serializable - data object SuggestionsPreview : WidgetsRoute + data object SuggestionsPreview : DeepLinkStart + + companion object { + private val DEEP_LINK_STARTS: List = listOf( + Gallery, + PricePreview, + PriceEdit, + WeatherPreview, + WeatherEdit, + BlocksPreview, + BlocksEdit, + HeadlinesPreview, + HeadlinesEdit, + FactsPreview, + CalculatorPreview, + SuggestionsPreview, + ) + + fun fromDeepLink(path: String): DeepLinkStart? = + ScreenDeepLinks.matchStart(path, Gallery, DEEP_LINK_STARTS) + } } @Preview(showSystemUi = true) diff --git a/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt index 610c12b34..dbb727f72 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/hardware/HardwareSheet.kt @@ -37,6 +37,7 @@ import to.bitkit.ui.components.SheetSize import to.bitkit.ui.navigateTo import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.utils.ScreenDeepLinks import to.bitkit.ui.utils.composableWithDefaultTransitions import to.bitkit.viewmodels.AppViewModel @@ -243,21 +244,34 @@ private fun ConnectEffectHandler( } sealed interface HardwareRoute { + sealed interface DeepLinkStart : HardwareRoute + + sealed interface InternalOnly : HardwareRoute + @Serializable - data object Intro : HardwareRoute + data object Intro : DeepLinkStart @Serializable - data object Searching : HardwareRoute + data object Searching : InternalOnly @Serializable data class Found( val deviceId: String? = null, val deviceModel: String = "", - ) : HardwareRoute + ) : InternalOnly @Serializable - data object Paired : HardwareRoute + data object Paired : InternalOnly @Serializable - data class PairCode(val requestId: Long) : HardwareRoute + data class PairCode(val requestId: Long) : InternalOnly + + companion object { + private val DEEP_LINK_STARTS: List = listOf( + Intro, + ) + + fun fromDeepLink(path: String): DeepLinkStart? = + ScreenDeepLinks.matchStart(path, Intro, DEEP_LINK_STARTS) + } } 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 000000000..c36abff2a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt @@ -0,0 +1,47 @@ +package to.bitkit.ui.utils + +import android.content.Intent +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])") + + fun screenId(route: KClass): String? = 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 matchStart(path: String, default: T, starts: List): T? = when { + path.isEmpty() -> default + else -> starts.firstOrNull { kebabId(it::class).equals(path, ignoreCase = true) } + } + + 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 + } +} 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 000000000..15d9b4e85 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt @@ -0,0 +1,55 @@ +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 +import kotlin.reflect.KClass + +object SheetDeepLinks { + private class Family( + val type: KClass, + val fromDeepLink: (String) -> Sheet?, + ) + + private val FAMILIES: List = listOf( + Family(Sheet.Send::class) { path -> SendRoute.fromDeepLink(path)?.let { Sheet.Send(it) } }, + Family(Sheet.Receive::class) { path -> ReceiveRoute.fromDeepLink(path)?.let { Sheet.Receive(it) } }, + Family(Sheet.Backup::class) { path -> BackupRoute.fromDeepLink(path)?.let { Sheet.Backup(it) } }, + Family(Sheet.Widgets::class) { path -> WidgetsRoute.fromDeepLink(path)?.let { Sheet.Widgets(it) } }, + Family(Sheet.Hardware::class) { path -> HardwareRoute.fromDeepLink(path)?.let { Sheet.Hardware(it) } }, + ) + + private val STANDALONE: List = listOf( + Sheet.ActivityDateRangeSelector, + Sheet.ActivityTagSelector, + Sheet.QrScanner(), + ) + + val sheetIds: Set + get() { + val families = FAMILIES.mapNotNull { ScreenDeepLinks.kebabId(it.type) } + val standalone = STANDALONE.mapNotNull { ScreenDeepLinks.kebabId(it::class) } + return (families + standalone).toSet() + } + + fun sheetFor(uri: Uri): Sheet? { + if (!ScreenDeepLinks.isScreenDeepLink(uri)) return null + + val segments = uri.pathSegments.orEmpty() + if (segments.isEmpty() || segments.size > 2) return null + + val id = segments.first().lowercase() + val child = segments.getOrElse(1) { "" } + + FAMILIES.firstOrNull { ScreenDeepLinks.kebabId(it.type) == id } + ?.let { return it.fromDeepLink(child) } + + if (child.isNotEmpty()) return null + + return STANDALONE.firstOrNull { ScreenDeepLinks.kebabId(it::class) == id } + } +} 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 d40cef1c1..303e6d9ce 100644 --- a/app/src/main/java/to/bitkit/ui/utils/Transitions.kt +++ b/app/src/main/java/to/bitkit/ui/utils/Transitions.kt @@ -20,6 +20,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.compose.navigation +import to.bitkit.ui.Routes import to.bitkit.ui.utils.Transitions.defaultEnterTrans import to.bitkit.ui.utils.Transitions.defaultExitTrans import to.bitkit.ui.utils.Transitions.defaultPopEnterTrans @@ -117,3 +118,27 @@ inline fun NavGraphBuilder.composableWithDefaultTransitions( content = content, ) } + +/** + * Adds a root screen that may be entered directly by URI, with the default screen transitions. + */ +@Suppress("LongParameterList", "MaxLineLength") +inline fun NavGraphBuilder.deepLinkableComposable( + typeMap: Map> = emptyMap(), + deepLinks: List = ScreenDeepLinks.linksFor(T::class), + noinline enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = defaultEnterTrans, + noinline exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition?)? = defaultExitTrans, + noinline popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = defaultPopEnterTrans, + noinline popExitTransition: (AnimatedContentTransitionScope.() -> ExitTransition?)? = defaultPopExitTrans, + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + composable( + typeMap = typeMap, + deepLinks = deepLinks, + enterTransition = enterTransition, + exitTransition = exitTransition, + popEnterTransition = popEnterTransition, + popExitTransition = popExitTransition, + content = content, + ) +} diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 563721c97..dec0eba47 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 + } + if (uri.isRecoveryModeDeeplink()) { lightningRepo.setRecoveryMode(enabled = true) delay(SCREEN_TRANSITION_DELAY) @@ -3308,6 +3322,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/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index 995c778e0..3ca936d62 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -1,10 +1,18 @@ package to.bitkit.ui 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 +34,25 @@ class ContentViewTest { assertEquals(Routes.SpendingHwSigned, transferEffectDestination(TransferEffect.OnHwTxSigned)) assertNull(transferEffectDestination(TransferEffect.OnOrderCreated)) } + + @Test + fun `a handled root screen link dismisses the open sheet`() { + val result = shouldDismissSheetForScreenLink(handled = true, currentSheet = Sheet.Receive()) + + assertTrue(result) + } + + @Test + fun `a rejected screen link leaves the open sheet alone`() { + val result = shouldDismissSheetForScreenLink(handled = false, currentSheet = Sheet.Receive()) + + assertFalse(result) + } + + @Test + fun `a handled root screen link with no sheet open dismisses nothing`() { + val result = shouldDismissSheetForScreenLink(handled = true, currentSheet = 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 new file mode 100644 index 000000000..0de5f16db --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/utils/ScreenDeepLinksTest.kt @@ -0,0 +1,135 @@ +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 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, + Routes.SavingsProgress::class, + Routes.SettingUp::class, + Routes.SpendingAdvanced::class, + Routes.SpendingConfirm::class, + Routes.SpendingHwSign::class, + Routes.SpendingHwSigned::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 `every route declares whether it may be entered directly`() { + val markers = setOf(Routes.DeepLinkable::class, Routes.InternalOnly::class) + + val undeclared = Routes::class.sealedSubclasses.filterNot { it in markers } + + assertTrue(undeclared.isEmpty(), "routes missing a DeepLinkable or InternalOnly decision: $undeclared") + } + + @Test + fun `every deep-linkable route has a unique screen id and one link`() { + val ids = mutableMapOf() + + Routes.DeepLinkable::class.sealedSubclasses.forEach { route -> + val name = route.simpleName.orEmpty() + val id = ScreenDeepLinks.screenId(route) + + 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 `internal routes are not deep-linkable`() { + val internal = Routes.InternalOnly::class.sealedSubclasses + val leaked = internal.filter { Routes.DeepLinkable::class.java.isAssignableFrom(it.java) } + + assertTrue(internal.isNotEmpty()) + assertTrue(leaked.isEmpty(), "internal routes exposed as deep-linkable: $leaked") + } + + @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 000000000..2ae94e8d3 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/utils/SheetDeepLinksTest.kt @@ -0,0 +1,169 @@ +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 to.bitkit.ui.sheets.hardware.HardwareRoute +import kotlin.reflect.KClass +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 `sheet ids are derived from the Sheet class names`() { + val expected = setOf( + "send", + "receive", + "backup", + "widgets", + "hardware", + "activity-date-range-selector", + "activity-tag-selector", + "qr-scanner", + ) + + assertEquals(expected, SheetDeepLinks.sheetIds) + } + + @Test + fun `every route marked as a start is registered in its family lookup`() { + val unreachable = mutableListOf() + + fun check(starts: List>, lookup: (String) -> Any?) { + starts.forEach { route -> + val id = ScreenDeepLinks.kebabId(route) ?: return@forEach + if (lookup(id) == null) unreachable += id + } + } + + check(SendRoute.DeepLinkStart::class.sealedSubclasses) { SendRoute.fromDeepLink(it) } + check(ReceiveRoute.DeepLinkStart::class.sealedSubclasses) { ReceiveRoute.fromDeepLink(it) } + check(BackupRoute.DeepLinkStart::class.sealedSubclasses) { BackupRoute.fromDeepLink(it) } + check(WidgetsRoute.DeepLinkStart::class.sealedSubclasses) { WidgetsRoute.fromDeepLink(it) } + check(HardwareRoute.DeepLinkStart::class.sealedSubclasses) { HardwareRoute.fromDeepLink(it) } + + assertTrue(unreachable.isEmpty(), "declared starts missing from their family lookup: $unreachable") + } + + @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 ee1916e39..91e251fe8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -105,6 +105,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 @@ -501,6 +502,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( @@ -2007,6 +2046,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 000000000..e878a811e --- /dev/null +++ b/changelog.d/next/1119.added.md @@ -0,0 +1 @@ +Developers running their own build can now open supported screens directly with `bitkit://screen/...` links while dev mode is enabled. diff --git a/journeys/deeplinks/screen-deeplink.xml b/journeys/deeplinks/screen-deeplink.xml new file mode 100644 index 000000000..e3e71243d --- /dev/null +++ b/journeys/deeplinks/screen-deeplink.xml @@ -0,0 +1,24 @@ + + 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 + 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 + + diff --git a/journeys/deeplinks/sheet-deeplink.xml b/journeys/deeplinks/sheet-deeplink.xml new file mode 100644 index 000000000..c59a3ef24 --- /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 + +