Skip to content
Original file line number Diff line number Diff line change
@@ -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<Context>()
val launchIntent = Intent(context, ComponentActivity::class.java)

ActivityScenario.launch<ComponentActivity>(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<out Routes>): 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<Routes.Home>(deepLinks = ScreenDeepLinks.linksFor(Routes.Home::class)) {
Text("home")
}
composable<Routes.Settings>(deepLinks = ScreenDeepLinks.linksFor(Routes.Settings::class)) {
Text("settings")
}
}
}
}
34 changes: 33 additions & 1 deletion app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package to.bitkit.ui

import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
Expand Down Expand Up @@ -48,6 +49,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
Expand Down Expand Up @@ -209,6 +211,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
Expand Down Expand Up @@ -301,6 +305,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
}

if (shouldDismissSheetForScreenLink(uri, appViewModel.currentSheet.value)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backup/show-mnemonic is denied by SheetDeepLinks, but it reaches this branch and hides the active backup intro before handleDeepLink reports the URI as unhandled. The new sheet journey therefore returns to the wallet overview instead of leaving the visible screen unchanged; any rejected screen URI can similarly dismiss the current sheet. Could we confirm that the URI matches a root destination before hiding the sheet and add regression coverage for a denied URI while a sheet is open?

appViewModel.hideSheet()
}

val request = Intent(Intent.ACTION_VIEW, uri)
Comment thread
ovitrif marked this conversation as resolved.
.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) {
Expand Down Expand Up @@ -964,7 +993,7 @@ private fun NavGraphBuilder.home(
onConsumeHomeWidgetsPageRequest: () -> Unit,
onCalculatorInputActiveChanged: (Boolean) -> Unit,
) {
composable<Routes.Home> {
composable<Routes.Home>(deepLinks = ScreenDeepLinks.linksFor(Routes.Home::class)) {
val isRefreshing by walletViewModel.isRefreshing.collectAsStateWithLifecycle()
val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle()
val hazeState = rememberHazeState()
Expand Down Expand Up @@ -1871,6 +1900,9 @@ fun NavController.navigateToTransferSpendingStart(
deviceId: String,
) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, deviceId))

internal fun shouldDismissSheetForScreenLink(uri: Uri, currentSheet: Sheet?): Boolean =
currentSheet != null && SheetDeepLinks.sheetFor(uri) == null

internal fun transferEffectDestination(effect: TransferEffect): Routes? = when (effect) {
TransferEffect.OnHwTxSigned -> Routes.SpendingHwSigned
TransferEffect.OnSpendingFundingPaid -> Routes.SettingUp
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -235,6 +236,10 @@ class MainActivity : FragmentActivity() {
}

appViewModel.handleDeeplinkIntent(intent)

if (ScreenDeepLinks.detachScreenUri(intent)) {
setIntent(intent)
}
}

/**
Expand Down
69 changes: 69 additions & 0 deletions app/src/main/java/to/bitkit/ui/utils/ScreenDeepLinks.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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])")

private val DENIED: Set<KClass<out Routes>> = setOf(
Comment thread
ovitrif marked this conversation as resolved.
Comment thread
ovitrif marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScreenDeepLinks becomes a second owner of the route model because DENIED duplicates direct-entry eligibility outside the sealed Routes hierarchy. Every destination change therefore needs to synchronize the route hierarchy, navigation graph, this registry, its tests, the documentation, and the journeys.

That duplication does not guard future changes:

  • A new route becomes externally reachable automatically because composableWithDefaultTransitions calls linksFor(T::class) by default.
  • A route that should remain internal can remain externally reachable because its author must also know to add it to DENIED.
  • The every-route test does not enforce an explicit eligibility decision because every unlisted route is accepted as deep-linkable.

The navigation model should follow the same ownership principle we use from DDD: behavior and invariants live with the model that owns them. Within this navigation boundary, the sealed Routes hierarchy should own whether a destination may be entered directly, while ScreenDeepLinks only adapts external URIs.

The sealed hierarchy should make that choice explicit:

sealed interface Routes {
    sealed interface DeepLinkable : Routes
    sealed interface InternalOnly : Routes

    @Serializable
    data object Settings : DeepLinkable

    @Serializable
    data object SpendingConfirm : InternalOnly
}

The graph helper should then accept only Routes.DeepLinkable, making every new destination declare its policy in the same model that defines it. Could we move root-screen eligibility into the sealed Routes hierarchy and constrain the graph helper to Routes.DeepLinkable, leaving ScreenDeepLinks responsible only for URI adaptation?

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,
)

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 <T : Any> linksFor(route: KClass<T>): List<NavDeepLink> {
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

fun detachScreenUri(intent: Intent): Boolean {
val uri = intent.data ?: return false
if (!isScreenDeepLink(uri)) return false

intent.data = null
return true
}

private fun isScreenRoute(route: KClass<*>): Boolean = Routes::class.java.isAssignableFrom(route.java)
}
81 changes: 81 additions & 0 deletions app/src/main/java/to/bitkit/ui/utils/SheetDeepLinks.kt
Original file line number Diff line number Diff line change
@@ -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<Sheet> = listOf(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SheetDeepLinks correctly fails closed because SHEETS is a positive allowlist. The maintenance defect is that this object becomes a second catalog of which nested states may safely start a flow, separate from the sealed SendRoute, ReceiveRoute, BackupRoute, WidgetsRoute, and HardwareRoute families that define those states.

Future flow changes therefore need synchronized maintenance:

  • A startable state needs to be declared in its route family and registered in SHEETS.
  • Reclassifying a flow-only state therefore changes this catalog and its parallel tests instead of one route contract.
  • The route declaration does not communicate whether the state is a valid independent entry point.

The nested navigation models should follow the same DDD ownership principle: each sealed route family owns the invariant that determines whether a state can start independently, while SheetDeepLinks only adapts the external URI.

Each sealed route family should make that choice explicit and own its lookup:

sealed interface SendRoute {
    sealed interface DeepLinkStart : SendRoute
    sealed interface InternalOnly : SendRoute

    @Serializable
    data object Recipient : DeepLinkStart

    @Serializable
    data object Confirm : InternalOnly

    companion object {
        fun fromDeepLink(path: String): DeepLinkStart? = TODO()
    }
}

SheetDeepLinks should keep the standalone Sheet entries it directly owns. For nested flows, it should select the sheet family, delegate the child path to that family’s route-owned lookup, and wrap the eligible state in the corresponding Sheet. Could we move nested sheet-entry eligibility and lookup into the existing sealed route families, leaving SheetDeepLinks as the family and standalone URI adapter?

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<String, Sheet> = 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<String> 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
}
}
Loading
Loading