mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
553ca2f8ed
commit
b0e91a390c
23 changed files with 325 additions and 75 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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")) }
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
|
@ -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) } }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)") },
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ actual fun ContactsEntryContent(
|
|||
scrollToTopEvents = scrollToTopEvents,
|
||||
sharedContactRequested = null,
|
||||
requestChannelSet = null,
|
||||
onHandleScannedUri = { _, _ -> },
|
||||
onHandleDeepLink = { _, _ -> },
|
||||
onClearSharedContactRequested = {},
|
||||
onClearRequestChannelUrl = {},
|
||||
initialContactKey = initialContactKey,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue