Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -209,6 +210,8 @@ import to.bitkit.ui.sheets.hardware.HardwareSheet
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.AutoReadClipboardHandler
import to.bitkit.ui.utils.RequestNotificationPermissions
import to.bitkit.ui.utils.ScreenDeepLinks
import to.bitkit.ui.utils.SheetDeepLinks
import to.bitkit.ui.utils.composableWithDefaultTransitions
import to.bitkit.ui.utils.navigationWithDefaultTransitions
import to.bitkit.ui.utils.rememberIs24HourFormat
Expand Down Expand Up @@ -301,6 +304,27 @@ fun ContentView(

LaunchedEffect(Unit) { walletViewModel.handleHideBalanceOnOpen() }

val pendingScreenDeepLink by appViewModel.pendingScreenDeepLink.collectAsStateWithLifecycle()

LaunchedEffect(pendingScreenDeepLink) {
val uri = pendingScreenDeepLink ?: return@LaunchedEffect

navController.currentBackStackEntryFlow.first()
appViewModel.consumeScreenDeepLink()

SheetDeepLinks.sheetFor(uri)?.let {
appViewModel.showSheet(it)
return@LaunchedEffect
}

val request = Intent(Intent.ACTION_VIEW, uri)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
val handled = navController.handleDeepLink(request)
if (!handled) {
Logger.warn("Unhandled screen deeplink '$uri'", context = "ContentView")
}
}

LaunchedEffect(appViewModel) {
appViewModel.mainScreenEffect.collect {
when (it) {
Expand Down Expand Up @@ -964,7 +988,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
8 changes: 8 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 @@ -234,7 +235,14 @@ class MainActivity : FragmentActivity() {
return
}

val isScreenLink = intent.data?.let { ScreenDeepLinks.isScreenDeepLink(it) } == true

appViewModel.handleDeeplinkIntent(intent)

if (isScreenLink) {
intent.data = null
setIntent(intent)
}
}

/**
Expand Down
54 changes: 54 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,54 @@
package to.bitkit.ui.utils

import android.net.Uri
import androidx.navigation.NavDeepLink
import androidx.navigation.navDeepLink
import to.bitkit.ui.Routes
import kotlin.reflect.KClass

object ScreenDeepLinks {
const val SCHEME = "bitkit"
const val HOST = "screen"

private const val BASE_URI = "$SCHEME://$HOST"

private val CAMEL_HUMP = Regex("(?<=[a-z0-9])(?=[A-Z])")

private val DENIED: Set<KClass<out Routes>> = setOf(

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.

I found that bitkit://screen/external-confirm creates a fresh ExternalNav-scoped view model with peer = null. Its enabled swipe reaches onConfirm(), which calls requireNotNull(peer) and crashes; external-amount can advance into the same state. Please deny these cold-start routes or restore and validate the required peer state before enabling confirmation, and cover both URIs from fresh state.

@guzino guzino Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Denied both, and external-success too, since it reports a completed channel open over the same empty state. Restoring the peer instead would mean re-running the connect flow validation from a URI, which belongs with that flow.

Resolved in 09239fb

Routes.AuthCheck::class,
Routes.CriticalUpdate::class,
Routes.ExternalAmount::class,
Routes.ExternalConfirm::class,
Routes.ExternalSuccess::class,
Routes.LegacyRnRecovery::class,
Routes.LnurlChannel::class,
Routes.RecoveryMnemonic::class,
Routes.RecoveryMode::class,
)

fun isDenied(route: KClass<*>): Boolean = route in DENIED

fun screenId(route: KClass<*>): String? {
if (!isScreenRoute(route)) return null
if (isDenied(route)) return null

return kebabId(route)
}

fun kebabId(route: KClass<*>): String? {
val name = route.simpleName ?: return null
return CAMEL_HUMP.split(name).joinToString("-") { it.lowercase() }
}

fun basePath(route: KClass<*>): String? = screenId(route)?.let { "$BASE_URI/$it" }

fun <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

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(
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
}
}
4 changes: 2 additions & 2 deletions app/src/main/java/to/bitkit/ui/utils/Transitions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ object Transitions {
inline fun <reified T : Any> NavGraphBuilder.navigationWithDefaultTransitions(
startDestination: Any,
typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(),
deepLinks: List<NavDeepLink> = emptyList(),
deepLinks: List<NavDeepLink> = ScreenDeepLinks.linksFor(T::class),
noinline enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultEnterTrans,
noinline exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = defaultExitTrans,
noinline popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultPopEnterTrans,
Expand All @@ -100,7 +100,7 @@ inline fun <reified T : Any> NavGraphBuilder.navigationWithDefaultTransitions(
@Suppress("LongParameterList", "MaxLineLength")
inline fun <reified T : Any> NavGraphBuilder.composableWithDefaultTransitions(
typeMap: Map<KType, NavType<*>> = emptyMap(),
deepLinks: List<NavDeepLink> = emptyList(),
deepLinks: List<NavDeepLink> = ScreenDeepLinks.linksFor(T::class),

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.

I found that bitkit://screen/home is generated but never attached to the graph. Routes.Home still uses raw composable<Routes.Home> in ContentView, so this default is skipped and the URI is unhandled from another screen. Please attach the link explicitly and test matching against the assembled graph.

@guzino guzino Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, Home is the last destination still on raw composable, so it never picked up the default and nothing was attached to the graph. Passed the links explicitly at that call site and confirmed it resolves from another screen now.

Resolved in 09239fb

noinline enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultEnterTrans,
noinline exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = defaultExitTrans,
noinline popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = defaultPopEnterTrans,
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -259,6 +260,9 @@ class AppViewModel @Inject constructor(
private val _isAuthenticated = MutableStateFlow(false)
val isAuthenticated = _isAuthenticated.asStateFlow()

private val _pendingScreenDeepLink = MutableStateFlow<Uri?>(null)
val pendingScreenDeepLink = _pendingScreenDeepLink.asStateFlow()

private val _showForgotPinSheet = MutableStateFlow(false)
val showForgotPinSheet = _showForgotPinSheet.asStateFlow()

Expand Down Expand Up @@ -3267,6 +3271,16 @@ class AppViewModel @Inject constructor(
return@launch
}

if (ScreenDeepLinks.isScreenDeepLink(uri)) {
if (!settingsStore.data.first().isDevModeEnabled) {
Logger.warn("Ignoring screen deeplink, dev mode is off", context = TAG)
return@launch
}

_pendingScreenDeepLink.value = uri
return@launch
}

if (uri.isRecoveryModeDeeplink()) {
lightningRepo.setRecoveryMode(enabled = true)
delay(SCREEN_TRANSITION_DELAY)
Expand Down Expand Up @@ -3296,6 +3310,10 @@ class AppViewModel @Inject constructor(
launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY)
}

fun consumeScreenDeepLink() {
_pendingScreenDeepLink.value = null
}

private fun Uri.isRecoveryModeDeeplink(): Boolean {
val normalizedScheme = scheme?.lowercase()
if (normalizedScheme != BITKIT_SCHEME) return false
Expand Down
Loading
Loading