feat: Add KMP URI handling, import, and QR code generation support (#4856)

This commit is contained in:
James Rich 2026-03-19 13:36:19 -05:00 committed by GitHub
parent 4eb711ce58
commit 1e55e554be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 379 additions and 209 deletions

View file

@ -46,16 +46,19 @@ import androidx.navigation3.runtime.rememberNavBackStack
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.first
import org.koin.core.context.startKoin
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.service.MeshServiceOrchestrator
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
import org.meshtastic.desktop.di.desktopModule
import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.ui.DesktopMainScreen
import org.meshtastic.desktop.ui.navSavedStateConfig
import java.awt.Desktop
import java.util.Locale
/**
@ -75,11 +78,35 @@ import java.util.Locale
private val LocalAppLocale = staticCompositionLocalOf { "" }
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun main() = application(exitProcessOnExit = false) {
fun main(args: Array<String>) = application(exitProcessOnExit = false) {
Logger.i { "Meshtastic Desktop — Starting" }
val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
val systemLocale = remember { Locale.getDefault() }
val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
LaunchedEffect(args) {
args.forEach { arg ->
if (
arg.startsWith("meshtastic://") ||
arg.startsWith("http://meshtastic.org") ||
arg.startsWith("https://meshtastic.org")
) {
uiViewModel.handleScannedUri(MeshtasticUri(arg)) {
Logger.e { "Invalid Meshtastic URI passed via args: $arg" }
}
}
}
}
LaunchedEffect(Unit) {
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" } }
}
}
}
// Start the mesh service processing chain (desktop equivalent of Android's MeshService)
val meshServiceController = remember { koinApp.koin.get<MeshServiceOrchestrator>() }

View file

@ -20,6 +20,7 @@ import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.desktop.ui.messaging.DesktopAdaptiveContactsScreen
import org.meshtastic.desktop.ui.messaging.DesktopMessageContent
@ -39,12 +40,18 @@ import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
fun EntryProviderScope<NavKey>.desktopMessagingGraph(backStack: NavBackStack<NavKey>) {
entry<ContactsRoutes.ContactsGraph> {
val viewModel: ContactsViewModel = koinViewModel()
DesktopAdaptiveContactsScreen(viewModel = viewModel)
DesktopAdaptiveContactsScreen(
viewModel = viewModel,
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
)
}
entry<ContactsRoutes.Contacts> {
val viewModel: ContactsViewModel = koinViewModel()
DesktopAdaptiveContactsScreen(viewModel = viewModel)
DesktopAdaptiveContactsScreen(
viewModel = viewModel,
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
)
}
entry<ContactsRoutes.Messages> { route ->

View file

@ -26,13 +26,13 @@ import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.desktop.ui.firmware.DesktopFirmwareScreen
import org.meshtastic.desktop.ui.map.KmpMapPlaceholder
import org.meshtastic.feature.connections.ui.ConnectionsScreen
import org.meshtastic.feature.settings.radio.channel.channelsGraph
/**
* Registers entry providers for all top-level desktop destinations.
@ -60,8 +60,7 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>)
desktopSettingsGraph(backStack)
// Channels
entry<ChannelsRoutes.ChannelsGraph> { PlaceholderScreen("Channels") }
entry<ChannelsRoutes.Channels> { PlaceholderScreen("Channels") }
channelsGraph(backStack)
// Connections — shared screen
entry<ConnectionsRoutes.ConnectionsGraph> {

View file

@ -37,6 +37,8 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ConnectionsRoutes
@ -49,6 +51,9 @@ import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
/**
@ -142,7 +147,11 @@ internal val navSavedStateConfig = SavedStateConfiguration {
* app, proving the shared backstack architecture works across targets.
*/
@Composable
fun DesktopMainScreen(backStack: NavBackStack<NavKey>, radioService: RadioInterfaceService = koinInject()) {
fun DesktopMainScreen(
backStack: NavBackStack<NavKey>,
radioService: RadioInterfaceService = koinInject(),
uiViewModel: UIViewModel = koinViewModel(),
) {
val currentKey = backStack.lastOrNull()
val selected = TopLevelDestination.fromNavKey(currentKey)
@ -150,6 +159,19 @@ fun DesktopMainScreen(backStack: NavBackStack<NavKey>, radioService: RadioInterf
val selectedDevice by radioService.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
if (connectionState == ConnectionState.Connected) {
sharedContactRequested?.let {
SharedContactDialog(sharedContact = it, onDismiss = { uiViewModel.clearSharedContactRequested() })
}
requestChannelSet?.let { newChannelSet ->
ScannedQrCodeDialog(newChannelSet, onDismiss = { uiViewModel.clearRequestChannelUrl() })
}
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail {

View file

@ -39,13 +39,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.resources.mark_as_read
import org.meshtastic.core.resources.unread_count
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.icon.MarkChatRead
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.component.EmptyConversationsPlaceholder
import org.meshtastic.feature.messaging.ui.contact.ContactItem
@ -63,13 +66,20 @@ import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod")
@Composable
fun DesktopAdaptiveContactsScreen(viewModel: ContactsViewModel) {
fun DesktopAdaptiveContactsScreen(
viewModel: ContactsViewModel,
onNavigateToShareChannels: () -> Unit = {},
uiViewModel: UIViewModel = koinViewModel(),
) {
val contacts by viewModel.contactList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val unreadTotal by viewModel.unreadCountTotal.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
@ -102,6 +112,23 @@ fun DesktopAdaptiveContactsScreen(viewModel: ContactsViewModel) {
onClickChip = {},
)
},
floatingActionButton = {
if (connectionState == ConnectionState.Connected) {
MeshtasticImportFAB(
onImport = { uriString ->
uiViewModel.handleScannedUri(
org.meshtastic.core.common.util.MeshtasticUri(uriString),
) {
// OnInvalid
}
},
onShareChannels = onNavigateToShareChannels,
sharedContact = sharedContactRequested,
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
isContactContext = true,
)
}
},
) { contentPadding ->
if (contacts.isEmpty()) {
EmptyConversationsPlaceholder(modifier = Modifier.padding(contentPadding))

View file

@ -48,14 +48,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.node_count_template
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
@ -77,12 +81,13 @@ import org.meshtastic.feature.node.model.NodeDetailAction
* bottom sheets) are no-ops on desktop.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun DesktopAdaptiveNodeListScreen(
viewModel: NodeListViewModel,
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
uiViewModel: UIViewModel = koinViewModel(),
) {
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
@ -96,6 +101,13 @@ fun DesktopAdaptiveNodeListScreen(
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
var shareNode by remember { mutableStateOf<org.meshtastic.core.model.Node?>(null) }
if (shareNode != null) {
SharedContactDialog(contact = shareNode, onDismiss = { shareNode = null })
}
LaunchedEffect(initialNodeId) {
initialNodeId?.let { nodeId -> navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
}
@ -124,6 +136,22 @@ fun DesktopAdaptiveNodeListScreen(
onClickChip = {},
)
},
floatingActionButton = {
if (connectionState == ConnectionState.Connected) {
MeshtasticImportFAB(
onImport = { uriString ->
uiViewModel.handleScannedUri(
org.meshtastic.core.common.util.MeshtasticUri(uriString),
) {
// OnInvalid
}
},
sharedContact = sharedContactRequested,
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
isContactContext = true,
)
}
},
) { contentPadding ->
Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
@ -227,6 +255,7 @@ fun DesktopAdaptiveNodeListScreen(
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction ->
detailViewModel.onServiceAction(action.action)
is NodeDetailAction.ShareContact -> shareNode = detailUiState.node
is NodeDetailAction.HandleNodeMenuAction -> {
val menuAction = action.action
if (