feat: implement unified deep link routing for Kotlin Multiplatform (#4910)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-24 19:21:24 -05:00 committed by GitHub
parent 553ca2f8ed
commit b0e91a390c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 325 additions and 75 deletions

View file

@ -18,7 +18,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose Multiplatform (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
- **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state.
- **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`.
- **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
- **Database:** Room KMP.

View file

@ -18,7 +18,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose Multiplatform (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
- **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state.
- **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`.
- **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
- **Database:** Room KMP.

View file

@ -64,7 +64,7 @@ The app follows modern Android development practices, built on top of a shared K
- **UI:** JetBrains Compose Multiplatform (Material 3) using Compose Multiplatform resources.
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
- **Dependency Injection:** Koin with Koin Annotations (K2 Compiler Plugin).
- **Navigation:** JetBrains Navigation 3 (Multiplatform routing).
- **Navigation:** JetBrains Navigation 3 (Multiplatform routing with RESTful deep linking).
- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms).
### Bluetooth Low Energy (BLE)

View file

@ -218,6 +218,27 @@
<data android:pathPrefix="/V/" />
</intent-filter>
<!-- App Links for modern RESTful navigation paths -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="meshtastic.org" />
<data android:pathPrefix="/share" />
<data android:pathPrefix="/connections" />
<data android:pathPrefix="/map" />
<data android:pathPrefix="/messages" />
<data android:pathPrefix="/quickchat" />
<data android:pathPrefix="/nodes" />
<data android:pathPrefix="/settings" />
<data android:pathPrefix="/channels" />
<data android:pathPrefix="/firmware" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>

View file

@ -211,14 +211,8 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
if (uri.toString().startsWith(DEEP_LINK_BASE_URI)) {
model.handleNavigationDeepLink(uri.toMeshtasticUri())
return
}
model.handleScannedUri(uri.toMeshtasticUri()) {
lifecycleScope.launch { showToast(Res.string.channel_invalid) }
}
model.handleDeepLink(uri.toMeshtasticUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
}
private fun createShareIntent(message: String): PendingIntent {

View file

@ -97,8 +97,17 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
@Composable
fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerViewModel = koinViewModel()) {
val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
// LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) }
// }
LaunchedEffect(uIViewModel) {
uIViewModel.navigationDeepLink.collect { uri ->
val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri)?.let { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
}
}
}
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
@ -239,6 +248,7 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
nodesGraph(
backStack = backStack,
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
onHandleDeepLink = uIViewModel::handleDeepLink,
nodeMapScreen = { destNum, onNavigateUp ->
val vm =
org.koin.compose.viewmodel.koinViewModel<

View file

@ -26,9 +26,11 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(projects.core.resources)
implementation(libs.kotlinx.serialization.core)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.kermit)
}
commonTest.dependencies { implementation(kotlin("test")) }

View file

@ -0,0 +1,213 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.navigation
import androidx.navigation3.runtime.NavKey
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.util.CommonUri
/**
* Type-safe deep link parser for KMP Navigation 3.
*
* Maps an incoming OS intent URI to a list of NavKeys representing the target backstack. This ensures that when a user
* deep links into a detail view, the logical "up" hierarchy is synthesized and correctly populated in the user-owned
* NavBackStack list.
*
* Supports both legacy query-parameter URIs and modern RESTful path patterns:
* - `/nodes` -> List of all nodes
* - `/nodes/{destNum}` -> Node details
* - `/nodes/{destNum}/{metric}` -> Specific node metric (e.g., `/nodes/1234/device-metrics`)
* - `/messages` -> Conversation list
* - `/messages/{contactKey}` -> Specific conversation
* - `/settings` -> Settings root
* - `/settings/{destNum}/{page}` -> Specific settings page for a node
* - `/share?message={text}` -> Share message screen
*/
object DeepLinkRouter {
/**
* Synthesizes a backstack list from an incoming Meshtastic URI.
*
* @param uri The incoming OS intent URI (e.g. "meshtastic://meshtastic/share?message=hello")
* @return A list of strongly-typed NavKeys representing the backstack, or null if the URI is not recognized.
*/
fun route(uri: CommonUri): List<NavKey>? {
val pathSegments = uri.pathSegments.filter { it.isNotBlank() }
if (pathSegments.isEmpty()) {
return null
}
val firstSegment = pathSegments[0].lowercase()
return when (firstSegment) {
"share",
"messages",
"quickchat",
-> routeContacts(uri, pathSegments)
"connections" -> listOf(ConnectionsRoutes.ConnectionsGraph)
"map" -> routeMap(uri, pathSegments)
"nodes" -> routeNodes(uri, pathSegments)
"settings" -> routeSettings(pathSegments)
"channels" -> listOf(ChannelsRoutes.ChannelsGraph)
"firmware" -> routeFirmware(pathSegments)
else -> {
Logger.w { "Unrecognized deep link segment: $firstSegment" }
null
}
}
}
private fun routeContacts(uri: CommonUri, segments: List<String>): List<NavKey> {
val firstSegment = segments[0].lowercase()
return when (firstSegment) {
"share" -> {
val message = uri.getQueryParameter("message") ?: ""
listOf(ContactsRoutes.ContactsGraph, ContactsRoutes.Share(message))
}
"quickchat" -> {
listOf(ContactsRoutes.ContactsGraph, ContactsRoutes.QuickChat)
}
"messages" -> {
val contactKey = if (segments.size > 1) segments[1] else uri.getQueryParameter("contactKey") ?: ""
val message = uri.getQueryParameter("message") ?: ""
if (contactKey.isNotBlank()) {
listOf(
ContactsRoutes.ContactsGraph,
ContactsRoutes.Messages(contactKey = contactKey, message = message),
)
} else {
listOf(ContactsRoutes.ContactsGraph)
}
}
else -> listOf(ContactsRoutes.ContactsGraph)
}
}
private fun routeMap(uri: CommonUri, segments: List<String>): List<NavKey> {
val waypointIdStr = if (segments.size > 1) segments[1] else uri.getQueryParameter("waypointId")
val waypointId = waypointIdStr?.toIntOrNull()
return listOf(MapRoutes.Map(waypointId))
}
private fun routeNodes(uri: CommonUri, segments: List<String>): List<NavKey> {
val destNumStr = if (segments.size > 1) segments[1] else uri.getQueryParameter("destNum")
val destNum = destNumStr?.toIntOrNull()
return if (destNum == null) {
listOf(NodesRoutes.NodesGraph)
} else if (segments.size > 2) {
val subRouteStr = segments[2].lowercase()
val detailRouteFn = nodeDetailSubRoutes[subRouteStr]
if (detailRouteFn != null) {
listOf(NodesRoutes.NodesGraph, NodesRoutes.NodeDetailGraph(destNum), detailRouteFn(destNum))
} else {
listOf(NodesRoutes.NodesGraph, NodesRoutes.NodeDetail(destNum))
}
} else {
listOf(NodesRoutes.NodesGraph, NodesRoutes.NodeDetail(destNum))
}
}
private fun routeSettings(segments: List<String>): List<NavKey> {
var destNum: Int? = null
var subRouteStr: String? = null
if (segments.size > 1) {
val secondSegment = segments[1]
val parsedNum = secondSegment.toIntOrNull()
if (parsedNum != null) {
destNum = parsedNum
if (segments.size > 2) {
subRouteStr = segments[2].lowercase()
}
} else {
subRouteStr = secondSegment.lowercase()
}
}
if (subRouteStr == null) {
return listOf(SettingsRoutes.SettingsGraph(destNum))
}
val subRoute = settingsSubRoutes[subRouteStr]
return if (subRoute != null) {
listOf(SettingsRoutes.SettingsGraph(destNum), subRoute)
} else {
listOf(SettingsRoutes.SettingsGraph(destNum))
}
}
private fun routeFirmware(segments: List<String>): List<NavKey> {
val update = if (segments.size > 1) segments[1].lowercase() == "update" else false
return if (update) {
listOf(FirmwareRoutes.FirmwareGraph, FirmwareRoutes.FirmwareUpdate)
} else {
listOf(FirmwareRoutes.FirmwareGraph)
}
}
private val settingsSubRoutes: Map<String, Route> =
mapOf(
"device-config" to SettingsRoutes.DeviceConfiguration,
"module-config" to SettingsRoutes.ModuleConfiguration,
"admin" to SettingsRoutes.Administration,
"user" to SettingsRoutes.User,
"channel" to SettingsRoutes.ChannelConfig,
"device" to SettingsRoutes.Device,
"position" to SettingsRoutes.Position,
"power" to SettingsRoutes.Power,
"network" to SettingsRoutes.Network,
"display" to SettingsRoutes.Display,
"lora" to SettingsRoutes.LoRa,
"bluetooth" to SettingsRoutes.Bluetooth,
"security" to SettingsRoutes.Security,
"mqtt" to SettingsRoutes.MQTT,
"serial" to SettingsRoutes.Serial,
"ext-notification" to SettingsRoutes.ExtNotification,
"store-forward" to SettingsRoutes.StoreForward,
"range-test" to SettingsRoutes.RangeTest,
"telemetry" to SettingsRoutes.Telemetry,
"canned-message" to SettingsRoutes.CannedMessage,
"audio" to SettingsRoutes.Audio,
"remote-hardware" to SettingsRoutes.RemoteHardware,
"neighbor-info" to SettingsRoutes.NeighborInfo,
"ambient-lighting" to SettingsRoutes.AmbientLighting,
"detection-sensor" to SettingsRoutes.DetectionSensor,
"paxcounter" to SettingsRoutes.Paxcounter,
"status-message" to SettingsRoutes.StatusMessage,
"traffic-management" to SettingsRoutes.TrafficManagement,
"tak" to SettingsRoutes.TAK,
"clean-node-db" to SettingsRoutes.CleanNodeDb,
"debug-panel" to SettingsRoutes.DebugPanel,
"about" to SettingsRoutes.About,
"filter-settings" to SettingsRoutes.FilterSettings,
)
private val nodeDetailSubRoutes: Map<String, (Int) -> Route> =
mapOf(
"device-metrics" to { destNum -> NodeDetailRoutes.DeviceMetrics(destNum) },
"map" to { destNum -> NodeDetailRoutes.NodeMap(destNum) },
"position" to { destNum -> NodeDetailRoutes.PositionLog(destNum) },
"environment" to { destNum -> NodeDetailRoutes.EnvironmentMetrics(destNum) },
"signal" to { destNum -> NodeDetailRoutes.SignalMetrics(destNum) },
"power" to { destNum -> NodeDetailRoutes.PowerMetrics(destNum) },
"traceroute" to { destNum -> NodeDetailRoutes.TracerouteLog(destNum) },
"host-metrics" to { destNum -> NodeDetailRoutes.HostMetricsLog(destNum) },
"pax" to { destNum -> NodeDetailRoutes.PaxMetrics(destNum) },
"neighbors" to { destNum -> NodeDetailRoutes.NeighborInfoLog(destNum) },
)
}

View file

@ -55,6 +55,7 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.jetbrains.navigation3.ui)
}
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }

View file

@ -20,10 +20,16 @@ import androidx.compose.runtime.Composable
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Encapsulates the headless, global UI components (dialogs, version checks, traceroute alerts) that need to be active
* across all platforms at the root of the application hierarchy.
* Common application-level setup for all Meshtastic platforms (Android, Desktop, etc.).
*
* This deduplicates the setup boilerplate from Android's MainScreen and DesktopMainScreen.
* This component encapsulates headless global UI logic that must reside at the root of the application hierarchy. It
* manages:
* - Shared system dialogs (e.g. contact/channel import)
* - Global version and firmware checks
* - System-wide alerts and snackbar hosts
* - Deep link navigation interception logic
*
* Platform hosts (Main.kt) should invoke this at the root of their theme before rendering the main NavDisplay.
*/
@Composable
fun MeshtasticCommonAppSetup(

View file

@ -87,18 +87,30 @@ class UIViewModel(
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
val navigationDeepLink = _navigationDeepLink.asSharedFlow()
fun handleNavigationDeepLink(uri: MeshtasticUri) {
_navigationDeepLink.tryEmit(uri)
}
/**
* Unified handler for all Meshtastic deep links and OS intents.
*
* This method orchestrates two distinct types of URI handling:
* 1. **Navigation:** First attempts to parse the URI into a typed [NavKey] backstack via [DeepLinkRouter]. If
* successful, navigates the user to the target screen.
* 2. **Data Import:** If navigation fails, falls back to legacy contact/channel parsing via
* [dispatchMeshtasticUri]. This triggers import dialogs for shared nodes or channel configurations.
*/
fun handleDeepLink(uri: MeshtasticUri, onInvalid: () -> Unit = {}) {
val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
fun handleScannedUri(uri: MeshtasticUri, onInvalid: () -> Unit) {
org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
.dispatchMeshtasticUri(
onContact = { setSharedContactRequested(it) },
onChannel = { setRequestChannelSet(it) },
onInvalid = onInvalid,
)
// Try navigation routing first
if (org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri) != null) {
_navigationDeepLink.tryEmit(uri)
return
}
// Fallback to channel/contact importing
commonUri.dispatchMeshtasticUri(
onContact = { setSharedContactRequested(it) },
onChannel = { setRequestChannelSet(it) },
onInvalid = onInvalid,
)
}
val theme: StateFlow<Int> = uiPrefs.theme

View file

@ -129,7 +129,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
arg.startsWith("http://meshtastic.org") ||
arg.startsWith("https://meshtastic.org")
) {
uiViewModel.handleScannedUri(MeshtasticUri(arg)) {
uiViewModel.handleDeepLink(MeshtasticUri(arg)) {
Logger.e { "Invalid Meshtastic URI passed via args: $arg" }
}
}
@ -140,7 +140,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) {
Desktop.getDesktop().setOpenURIHandler { event ->
val uriStr = event.uri.toString()
uiViewModel.handleScannedUri(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } }
uiViewModel.handleDeepLink(MeshtasticUri(uriStr)) { Logger.e { "Invalid URI from OS: $uriStr" } }
}
}
}

View file

@ -36,10 +36,14 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
* [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their
* shared composables are wired.
*/
fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>) {
fun EntryProviderScope<NavKey>.desktopNavGraph(
backStack: NavBackStack<NavKey>,
uiViewModel: org.meshtastic.core.ui.viewmodel.UIViewModel,
) {
// Nodes — real composables from feature:node
nodesGraph(
backStack = backStack,
onHandleDeepLink = uiViewModel::handleDeepLink,
nodeMapScreen = { destNum, _ -> KmpMapPlaceholder(title = "Node Map ($destNum)") },
)

View file

@ -27,6 +27,7 @@ import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@ -65,6 +66,16 @@ fun DesktopMainScreen(
val currentKey = backStack.lastOrNull()
val selected = TopLevelDestination.fromNavKey(currentKey)
LaunchedEffect(uiViewModel) {
uiViewModel.navigationDeepLink.collect { uri ->
val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri)?.let { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
}
}
}
val connectionState by radioService.connectionState.collectAsStateWithLifecycle()
val selectedDevice by radioService.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
@ -113,7 +124,7 @@ fun DesktopMainScreen(
modifier = Modifier.weight(1f).fillMaxSize(),
hostModifier = Modifier.padding(bottom = 24.dp),
) {
val provider = entryProvider<NavKey> { desktopNavGraph(backStack) }
val provider = entryProvider<NavKey> { desktopNavGraph(backStack, uiViewModel) }
NavDisplay(
backStack = backStack,

View file

@ -35,6 +35,8 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver
- Do mutate `NavBackStack<NavKey>` with `add(...)` and `removeLastOrNull()`.
- Don't use Android's `androidx.activity.compose.BackHandler` or custom `PredictiveBackHandler` in multiplatform UI.
- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures.
- Don't parse deep links manually in platform code or push single routes without a backstack.
- Do use `DeepLinkRouter.route()` in `core:navigation` to synthesize the correct typed backstack from RESTful paths.
### Current code anchors (Navigation 3)

View file

@ -50,7 +50,7 @@ actual fun ContactsEntryContent(
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onHandleDeepLink = uiViewModel::handleDeepLink,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = initialContactKey,

View file

@ -52,7 +52,7 @@ fun AdaptiveContactsScreen(
scrollToTopEvents: Flow<ScrollToTopEvent>,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleScannedUri: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,
@ -96,7 +96,7 @@ fun AdaptiveContactsScreen(
onNavigateToShare = { backStack.add(ChannelsRoutes.ChannelsGraph) },
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = onHandleScannedUri,
onHandleDeepLink = onHandleDeepLink,
onClearSharedContactRequested = onClearSharedContactRequested,
onClearRequestChannelUrl = onClearRequestChannelUrl,
viewModel = contactsViewModel,

View file

@ -115,7 +115,7 @@ fun ContactsScreen(
onNavigateToShare: () -> Unit,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleScannedUri: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onHandleDeepLink: (MeshtasticUri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel,
@ -253,7 +253,7 @@ fun ContactsScreen(
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uriString ->
onHandleScannedUri(MeshtasticUri(uriString)) {
onHandleDeepLink(MeshtasticUri(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
},

View file

@ -42,7 +42,7 @@ actual fun ContactsEntryContent(
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = null,
requestChannelSet = null,
onHandleScannedUri = { _, _ -> },
onHandleDeepLink = { _, _ -> },
onClearSharedContactRequested = {},
onClearRequestChannelUrl = {},
initialContactKey = initialContactKey,

View file

@ -57,11 +57,9 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.proto.SharedContact
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@ -71,6 +69,7 @@ fun NodeListScreen(
onNavigateToChannels: () -> Unit = {},
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val showToast = org.meshtastic.core.ui.util.rememberShowToastResource()
val scope = rememberCoroutineScope()
@ -100,9 +99,6 @@ fun NodeListScreen(
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
}
val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle()
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { viewModel.clearRequestChannelSet() }) }
Scaffold(
topBar = {
MainAppBar(
@ -118,14 +114,13 @@ fun NodeListScreen(
},
floatingActionButton = {
val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false
val sharedContact: SharedContact? by viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
if (!isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable) {
MeshtasticImportFAB(
sharedContact = sharedContact,
onImport = { uriString ->
viewModel.handleScannedUri(uriString) { scope.launch { showToast(Res.string.channel_invalid) } }
onHandleDeepLink(org.meshtastic.core.common.util.MeshtasticUri(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
},
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
isContactContext = true,
)
}

View file

@ -20,18 +20,14 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
@ -40,7 +36,6 @@ import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
@Suppress("LongParameterList")
@KoinViewModel
@ -63,12 +58,6 @@ class NodeListViewModel(
val connectionState = serviceRepository.connectionState
private val _sharedContactRequested: MutableStateFlow<SharedContact?> = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
private val _requestChannelSet = MutableStateFlow<ChannelSet?>(null)
val requestChannelSet = _requestChannelSet.asStateFlow()
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
@ -135,24 +124,6 @@ class NodeListViewModel(
nodeFilterPreferences.setNodeSort(sort)
}
fun setSharedContactRequested(sharedContact: SharedContact?) {
_sharedContactRequested.value = sharedContact
}
/** Unified handler for scanned Meshtastic URIs (contacts or channels). */
fun handleScannedUri(uriString: String, onInvalid: () -> Unit) {
val uri = CommonUri.parse(uriString)
uri.dispatchMeshtasticUri(
onContact = { _sharedContactRequested.value = it },
onChannel = { _requestChannelSet.value = it },
onInvalid = onInvalid,
)
}
fun clearRequestChannelSet() {
_requestChannelSet.value = null
}
fun setChannels(channelSet: ChannelSet) = viewModelScope.launch {
radioConfigRepository.replaceAllSettings(channelSet.settings)
val newLoraConfig = channelSet.lora_config

View file

@ -52,6 +52,7 @@ fun AdaptiveNodeListScreen(
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
) {
val nodeListViewModel: NodeListViewModel = koinViewModel()
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
@ -85,6 +86,7 @@ fun AdaptiveNodeListScreen(
onNavigateToChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
scrollToTopEvents = scrollToTopEvents,
activeNodeId = activeNodeId,
onHandleDeepLink = onHandleDeepLink,
)
},
detailPane = { contentKey, handleBack ->

View file

@ -67,6 +67,7 @@ import kotlin.reflect.KClass
fun EntryProviderScope<NavKey>.nodesGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit = { _, _ -> },
) {
entry<NodesRoutes.NodesGraph> {
@ -75,6 +76,7 @@ fun EntryProviderScope<NavKey>.nodesGraph(
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
@ -84,16 +86,18 @@ fun EntryProviderScope<NavKey>.nodesGraph(
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
nodeDetailGraph(backStack, scrollToTopEvents, nodeMapScreen)
nodeDetailGraph(backStack, scrollToTopEvents, onHandleDeepLink, nodeMapScreen)
}
@Suppress("LongMethod")
fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
onHandleDeepLink: (org.meshtastic.core.common.util.MeshtasticUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit,
) {
entry<NodesRoutes.NodeDetailGraph> { args ->
@ -103,6 +107,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}
@ -113,6 +118,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
onHandleDeepLink = onHandleDeepLink,
)
}