From 1e55e554be482bffa85269a0c2605cbde1d5d796 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:36:19 -0500 Subject: [PATCH] feat: Add KMP URI handling, import, and QR code generation support (#4856) --- .github/workflows/pull-request.yml | 1 + AGENTS.md | 3 +- GEMINI.md | 3 +- .../kotlin/org/meshtastic/app/MainActivity.kt | 4 ++ .../main/kotlin/org/meshtastic/app/ui/Main.kt | 2 +- .../app/ui/NavigationAssemblyTest.kt | 2 +- conductor/desktop-uri-import-plan.md | 31 ++++++++ core/model/build.gradle.kts | 1 - .../meshtastic/core/model/util/QrCodeUtils.kt | 52 -------------- core/ui/build.gradle.kts | 6 +- .../org/meshtastic/core/ui/util/QrUtils.kt | 70 ------------------- .../meshtastic/core/ui/util/ScreenUtils.kt | 39 +++++++++++ .../core/ui/component/ImportFabUiTest.kt | 36 +++++++++- .../core/ui/component/ContactSharing.kt | 7 +- .../meshtastic/core/ui/component/ImportFab.kt | 37 +++++++--- .../meshtastic/core/ui/component/QrDialog.kt | 9 ++- .../ui/util/LocalBarcodeScannerProvider.kt | 2 + .../core/ui/util/LocalNfcScannerProvider.kt | 2 + .../org/meshtastic/core/ui/util/QrUtils.kt | 63 +++++++++++++++-- .../meshtastic/core/ui/util/ScreenUtils.kt | 26 +++++++ .../ui/util/{QrUtils.kt => ScreenUtils.kt} | 6 +- .../kotlin/org/meshtastic/desktop/Main.kt | 29 +++++++- .../navigation/DesktopMessagingNavigation.kt | 11 ++- .../desktop/navigation/DesktopNavigation.kt | 5 +- .../desktop/ui/DesktopMainScreen.kt | 24 ++++++- .../DesktopAdaptiveContactsScreen.kt | 29 +++++++- .../ui/nodes/DesktopAdaptiveNodeListScreen.kt | 31 +++++++- docs/agent-playbooks/README.md | 4 +- docs/kmp-status.md | 8 +-- docs/roadmap.md | 1 + .../settings/radio/channel/ChannelScreen.kt} | 40 +++-------- .../radio/channel}/ChannelsNavigation.kt | 2 +- gradle/libs.versions.toml | 2 + 33 files changed, 379 insertions(+), 209 deletions(-) create mode 100644 conductor/desktop-uri-import-plan.md delete mode 100644 core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/QrCodeUtils.kt delete mode 100644 core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt create mode 100644 core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt rename core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/{QrUtils.kt => ScreenUtils.kt} (79%) rename feature/settings/src/{androidMain/kotlin/org/meshtastic/feature/settings/navigation/Channel.kt => commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt} (93%) rename feature/settings/src/{androidMain/kotlin/org/meshtastic/feature/settings/navigation => commonMain/kotlin/org/meshtastic/feature/settings/radio/channel}/ChannelsNavigation.kt (96%) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index a59e66500..e8cfb68c6 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -24,6 +24,7 @@ jobs: - uses: dorny/paths-filter@v4 id: filter with: + token: '' filters: | android: # CI/workflow implementation diff --git a/AGENTS.md b/AGENTS.md index f4a27a065..9e5f6d412 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core/ble/` | Bluetooth Low Energy stack using Kable. | | `core/resources/` | Centralized string and image resources (Compose Multiplatform). | | `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** | -| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`). All are KMP with `jvm()` target. Use `meshtastic.kmp.feature` convention plugin. | +| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` target except `widget`. Use `meshtastic.kmp.feature` convention plugin. | | `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | | `mesh_service_example/` | Sample app showing `core:api` service integration. | @@ -77,6 +77,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. - **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main. - **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. +- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code. - **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes. - **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. diff --git a/GEMINI.md b/GEMINI.md index f4a27a065..9e5f6d412 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -50,7 +50,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec | `core/ble/` | Bluetooth Low Energy stack using Kable. | | `core/resources/` | Centralized string and image resources (Compose Multiplatform). | | `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** | -| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`). All are KMP with `jvm()` target. Use `meshtastic.kmp.feature` convention plugin. | +| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` target except `widget`. Use `meshtastic.kmp.feature` convention plugin. | | `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. | | `mesh_service_example/` | Sample app showing `core:api` service integration. | @@ -77,6 +77,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`. - **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main. - **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`. +- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code. - **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes. - **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative. diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index a7a4e23bd..c590d11a4 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -64,9 +64,11 @@ import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.MODE_DYNAMIC import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider +import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalInlineMapProvider import org.meshtastic.core.ui.util.LocalMapViewProvider import org.meshtastic.core.ui.util.LocalNfcScannerProvider +import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider import org.meshtastic.core.ui.util.showToast import org.meshtastic.core.ui.viewmodel.UIViewModel @@ -124,6 +126,8 @@ class MainActivity : ComponentActivity() { CompositionLocalProvider( LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) }, LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) }, + LocalBarcodeScannerSupported provides true, + LocalNfcScannerSupported provides true, LocalAnalyticsIntroProvider provides { AnalyticsIntro() }, LocalMapViewProvider provides getMapViewProvider(), LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) }, diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 1e55b7263..346be20af 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -106,8 +106,8 @@ import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.map.navigation.mapGraph import org.meshtastic.feature.messaging.navigation.contactsGraph import org.meshtastic.feature.node.navigation.nodesGraph -import org.meshtastic.feature.settings.navigation.channelsGraph import org.meshtastic.feature.settings.navigation.settingsGraph +import org.meshtastic.feature.settings.radio.channel.channelsGraph @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod", "CyclomaticComplexMethod") diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt index f21c692ee..e4bb2aba4 100644 --- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -30,8 +30,8 @@ import org.meshtastic.feature.firmware.navigation.firmwareGraph import org.meshtastic.feature.map.navigation.mapGraph import org.meshtastic.feature.messaging.navigation.contactsGraph import org.meshtastic.feature.node.navigation.nodesGraph -import org.meshtastic.feature.settings.navigation.channelsGraph import org.meshtastic.feature.settings.navigation.settingsGraph +import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config diff --git a/conductor/desktop-uri-import-plan.md b/conductor/desktop-uri-import-plan.md new file mode 100644 index 000000000..f863484ea --- /dev/null +++ b/conductor/desktop-uri-import-plan.md @@ -0,0 +1,31 @@ +# Desktop URI Import Plan + +## Objective +Wire up `SharedContact` and `ChannelSet` import logic for the Desktop target. This enables the Desktop app to process deep links or URIs passed on startup via arguments or intercepted by the OS using `java.awt.Desktop`'s `OpenURIHandler`. + +## Key Files & Context +- `desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt`: Desktop app entry point. Must be updated to parse command line arguments and handle OS-level URI opening events. +- `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`: The main UI composition. Must be updated to inject the shared `UIViewModel` and render the `SharedContactDialog` / `ScannedQrCodeDialog` when `requestChannelSet` or `sharedContactRequested` are present. +- `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt`: Already handles URI dispatch and holds the requests, so no changes are needed here. + +## Implementation Steps + +1. **Update `DesktopMainScreen.kt`** + - Import `org.meshtastic.core.ui.viewmodel.UIViewModel`, `org.koin.compose.viewmodel.koinViewModel`, `org.meshtastic.core.ui.share.SharedContactDialog`, `org.meshtastic.core.ui.qr.ScannedQrCodeDialog`, and `org.meshtastic.core.model.ConnectionState`. + - Inject `UIViewModel` directly into `DesktopMainScreen` via `val uiViewModel = koinViewModel()`. + - Add observations for `uiViewModel.sharedContactRequested` and `uiViewModel.requestChannelSet`. + - Just like in Android's `MainScreen`, conditionally render `SharedContactDialog` and `ScannedQrCodeDialog` if `connectionState == ConnectionState.Connected` and either state contains a valid request. + - Wire `onDismiss` closures to `uiViewModel.clearSharedContactRequested()` and `uiViewModel.clearRequestChannelUrl()`. + +2. **Update `Main.kt` (Desktop)** + - Alter `fun main()` to `fun main(args: Array)`. + - Resolve `UIViewModel` after `koinApp` initialization: `val uiViewModel = koinApp.koin.get()`. + - Process the initial `args` and invoke `uiViewModel.handleScannedUri` using `MeshtasticUri` for any arguments that look like valid Meshtastic URIs (starting with `http` or `meshtastic://`). + - Attempt to attach a `java.awt.desktop.OpenURIHandler` if `java.awt.Desktop.Action.APP_OPEN_URI` is supported. When triggered, process the incoming `event.uri` string using the same `handleScannedUri` logic. + +## Verification & Testing +1. Compile the desktop target with `./gradlew desktop:run --args="meshtastic://meshtastic/v/contact..."`. +2. Connect to a device via Desktop Connections or wait for connection. +3. Validate that the corresponding Shared Contact or Channel Set dialog renders on screen. +4. Verify that dismissing the dialogs properly clears the state in the view model. +5. (Optional, macOS) If testing via packaged DMG, verify that opening a `.webloc` or invoking `open meshtastic://...` triggers the `APP_OPEN_URI` handler and routes through the UI. \ No newline at end of file diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index f3c4b54b6..4726457fd 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -51,7 +51,6 @@ kotlin { androidMain.dependencies { api(libs.androidx.annotation) api(libs.androidx.core.ktx) - implementation(libs.zxing.core) } val androidHostTest by getting { dependencies { diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/QrCodeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/QrCodeUtils.kt deleted file mode 100644 index 9c38c4d4f..000000000 --- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/QrCodeUtils.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2025-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 . - */ -@file:Suppress("MagicNumber", "TooGenericExceptionCaught") - -package org.meshtastic.core.model.util - -import android.graphics.Bitmap -import co.touchlab.kermit.Logger -import com.google.zxing.BarcodeFormat -import com.google.zxing.MultiFormatWriter -import com.google.zxing.common.BitMatrix -import org.meshtastic.proto.ChannelSet - -fun ChannelSet.qrCode(shouldAdd: Boolean): Bitmap? = try { - val multiFormatWriter = MultiFormatWriter() - val url = getChannelUrl(false, shouldAdd) - val bitMatrix = multiFormatWriter.encode(url.toString(), BarcodeFormat.QR_CODE, 960, 960) - bitMatrix.toBitmap() -} catch (ex: Throwable) { - Logger.e(ex) { "URL was too complex to render as barcode" } - null -} - -private fun BitMatrix.toBitmap(): Bitmap { - val width = width - val height = height - val pixels = IntArray(width * height) - for (y in 0 until height) { - val offset = y * width - for (x in 0 until width) { - // Black: 0xFF000000, White: 0xFFFFFFFF - pixels[offset + x] = if (get(x, y)) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() - } - } - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - bitmap.setPixels(pixels, 0, width, 0, 0, width, height) - return bitmap -} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 5b6f20ddc..8dc0af751 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -52,12 +52,10 @@ kotlin { implementation(libs.kermit) implementation(libs.koin.compose.viewmodel) + implementation(libs.qrcode.kotlin) } - androidMain.dependencies { - implementation(libs.androidx.activity.compose) - implementation(libs.zxing.core) - } + androidMain.dependencies { implementation(libs.androidx.activity.compose) } commonTest.dependencies { implementation(libs.junit) diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt deleted file mode 100644 index 768a4f427..000000000 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2025-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 . - */ -package org.meshtastic.core.ui.util - -import android.graphics.Bitmap -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalContext -import com.google.zxing.BarcodeFormat -import com.google.zxing.MultiFormatWriter -import com.google.zxing.common.BitMatrix - -actual fun generateQrCode(text: String, size: Int): ImageBitmap? = try { - val multiFormatWriter = MultiFormatWriter() - val bitMatrix = multiFormatWriter.encode(text, BarcodeFormat.QR_CODE, size, size) - bitMatrix.toBitmap().asImageBitmap() -} catch (e: com.google.zxing.WriterException) { - co.touchlab.kermit.Logger.e(e) { "Failed to generate QR code" } - null -} - -private fun BitMatrix.toBitmap(): Bitmap { - val pixels = IntArray(width * height) - for (y in 0 until height) { - val offset = y * width - for (x in 0 until width) { - pixels[offset + x] = if (get(x, y)) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() - } - } - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - bitmap.setPixels(pixels, 0, width, 0, 0, width, height) - return bitmap -} - -@Composable -actual fun SetScreenBrightness(brightness: Float) { - val context = LocalContext.current - DisposableEffect(Unit) { - val activity = context.findActivity() - val originalBrightness = activity?.window?.attributes?.screenBrightness ?: -1f - activity?.window?.let { window -> - val params = window.attributes - params.screenBrightness = brightness - window.attributes = params - } - onDispose { - activity?.window?.let { window -> - val params = window.attributes - params.screenBrightness = originalBrightness - window.attributes = params - } - } - } -} diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt new file mode 100644 index 000000000..60d4da59a --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt @@ -0,0 +1,39 @@ +/* + * 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 . + */ +package org.meshtastic.core.ui.util + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun SetScreenBrightness(brightness: Float) { + val context = LocalContext.current + DisposableEffect(Unit) { + val window = (context as? Activity)?.window + val layoutParams = window?.attributes + val originalBrightness = layoutParams?.screenBrightness + layoutParams?.screenBrightness = brightness + window?.attributes = layoutParams + + onDispose { + layoutParams?.screenBrightness = originalBrightness ?: -1f + window?.attributes = layoutParams + } + } +} diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt index 3c608b5bd..460a96bc7 100644 --- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt +++ b/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt @@ -17,6 +17,8 @@ package org.meshtastic.core.ui.component import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertDoesNotExist import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -24,6 +26,8 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import org.junit.Rule import org.junit.Test +import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported +import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User @@ -32,9 +36,16 @@ class ImportFabUiTest { @get:Rule val composeTestRule = createComposeRule() @Test - fun importFab_expands_onButtonClick() { + fun importFab_expands_onButtonClick_whenSupported() { val testTag = "import_fab" - composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) } + composeTestRule.setContent { + CompositionLocalProvider( + LocalBarcodeScannerSupported provides true, + LocalNfcScannerSupported provides true, + ) { + MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) + } + } // Expand the FAB composeTestRule.onNodeWithTag(testTag).performClick() @@ -45,6 +56,27 @@ class ImportFabUiTest { composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() } + @Test + fun importFab_hidesNfcAndQr_whenNotSupported() { + val testTag = "import_fab" + composeTestRule.setContent { + CompositionLocalProvider( + LocalBarcodeScannerSupported provides false, + LocalNfcScannerSupported provides false, + ) { + MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) + } + } + + // Expand the FAB + composeTestRule.onNodeWithTag(testTag).performClick() + + // Verify menu items are visible using their tags + composeTestRule.onNodeWithTag("nfc_import").assertDoesNotExist() + composeTestRule.onNodeWithTag("qr_import").assertDoesNotExist() + composeTestRule.onNodeWithTag("url_import").assertIsDisplayed() + } + @Test fun importFab_showsUrlDialog_whenUrlItemClicked() { val testTag = "import_fab" diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt index 65cb2f6d9..5dbe4b479 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt @@ -19,13 +19,12 @@ package org.meshtastic.core.ui.component import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.getSharedContactUrl import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.share_contact -import org.meshtastic.core.ui.util.generateQrCode +import org.meshtastic.core.ui.util.rememberQrCodePainter import org.meshtastic.proto.SharedContact /** @@ -40,11 +39,11 @@ fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) { val contactToShare = SharedContact(user = contact.user, node_num = contact.num) val commonUri = contactToShare.getSharedContactUrl() val uriString = commonUri.toString() - val qrCode = remember(uriString) { generateQrCode(uriString, 960) } + val qrPainter = rememberQrCodePainter(uriString, 960) QrDialog( title = stringResource(Res.string.share_contact), uriString = uriString, - qrCode = qrCode, + qrPainter = qrPainter, onDismiss = onDismiss, ) } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt index e601168b8..edda19c65 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ImportFab.kt @@ -56,7 +56,9 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.QrCode2 import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider +import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported import org.meshtastic.core.ui.util.LocalNfcScannerProvider +import org.meshtastic.core.ui.util.LocalNfcScannerSupported import org.meshtastic.core.ui.util.rememberOpenNfcSettings import org.meshtastic.proto.SharedContact @@ -97,6 +99,8 @@ fun MeshtasticImportFAB( val barcodeScanner = LocalBarcodeScannerProvider.current { contents -> contents?.let { onImport(it) } } val nfcScanner = LocalNfcScannerProvider.current + val isNfcSupported = LocalNfcScannerSupported.current + val isBarcodeSupported = LocalBarcodeScannerSupported.current if (isNfcScanning) { nfcScanner( @@ -142,8 +146,10 @@ fun MeshtasticImportFAB( ) } - val items = - mutableListOf( + val items = mutableListOf() + + if (isNfcSupported) { + items.add( MenuFABItem( label = stringResource( @@ -153,6 +159,11 @@ fun MeshtasticImportFAB( onClick = { isNfcScanning = true }, testTag = "nfc_import", ), + ) + } + + if (isBarcodeSupported) { + items.add( MenuFABItem( label = stringResource( @@ -162,16 +173,20 @@ fun MeshtasticImportFAB( onClick = { barcodeScanner.startScan() }, testTag = "qr_import", ), - MenuFABItem( - label = - stringResource( - if (isContactContext) Res.string.input_shared_contact_url else Res.string.input_channel_url, - ), - icon = Icons.Rounded.Link, - onClick = { showUrlDialog = true }, - testTag = "url_import", - ), ) + } + + items.add( + MenuFABItem( + label = + stringResource( + if (isContactContext) Res.string.input_shared_contact_url else Res.string.input_channel_url, + ), + icon = Icons.Rounded.Link, + onClick = { showUrlDialog = true }, + testTag = "url_import", + ), + ) onShareChannels?.let { items.add( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt index 1ff844537..d72c4cde0 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt @@ -34,8 +34,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.style.TextOverflow @@ -53,7 +52,7 @@ import org.meshtastic.core.ui.util.createClipEntry private const val QR_IMAGE_SIZE = 320 @Composable -fun QrDialog(title: String, uriString: String, qrCode: ImageBitmap?, onDismiss: () -> Unit) { +fun QrDialog(title: String, uriString: String, qrPainter: Painter?, onDismiss: () -> Unit) { val clipboardManager = LocalClipboard.current val coroutineScope = rememberCoroutineScope() val label = stringResource(Res.string.url) @@ -67,9 +66,9 @@ fun QrDialog(title: String, uriString: String, qrCode: ImageBitmap?, onDismiss: onConfirm = onDismiss, text = { Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - if (qrCode != null) { + if (qrPainter != null) { Image( - painter = BitmapPainter(qrCode), + painter = qrPainter, contentDescription = stringResource(Res.string.qr_code), modifier = Modifier.size(QR_IMAGE_SIZE.dp), contentScale = ContentScale.Fit, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalBarcodeScannerProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalBarcodeScannerProvider.kt index 79b02e2da..b5e94c9d0 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalBarcodeScannerProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalBarcodeScannerProvider.kt @@ -29,3 +29,5 @@ val LocalBarcodeScannerProvider = } } } + +val LocalBarcodeScannerSupported = compositionLocalOf { false } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNfcScannerProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNfcScannerProvider.kt index 837289bbe..1a6b84e3a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNfcScannerProvider.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNfcScannerProvider.kt @@ -21,3 +21,5 @@ import androidx.compose.runtime.compositionLocalOf val LocalNfcScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } } + +val LocalNfcScannerSupported = compositionLocalOf { false } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt index 38e942fa1..7ebcd1b2b 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt @@ -17,14 +17,65 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap - -/** Generates a QR code for the given text. */ -expect fun generateQrCode(text: String, size: Int): ImageBitmap? +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import qrcode.QRCode /** - * A Composable that sets the screen brightness while it is in the composition. + * Generates a QR code painter directly using the Skia/Compose canvas API in pure Kotlin. * - * @param brightness The brightness value (0.0 to 1.0). + * This implementation avoids any platform-specific bitmap APIs (like Android's [android.graphics.Bitmap] or Java AWT's + * BufferedImage), making it fully compatible with Android, Desktop, iOS, and Web. */ -@Composable expect fun SetScreenBrightness(brightness: Float) +@Suppress("MagicNumber") +@Composable +fun rememberQrCodePainter(text: String, size: Int = 512): Painter { + val qrCode = androidx.compose.runtime.remember(text) { QRCode.ofSquares().build(text) } + val rawMatrix = androidx.compose.runtime.remember(qrCode) { qrCode.rawData } + val matrixSize = androidx.compose.runtime.remember(qrCode) { rawMatrix.size } + val quietZone = 4 // QR standard quiet zone is 4 modules on all sides + val totalModules = matrixSize + (quietZone * 2) + + return androidx.compose.runtime.remember(qrCode, size) { + val bitmap = ImageBitmap(size, size) + val canvas = androidx.compose.ui.graphics.Canvas(bitmap) + val drawScope = CanvasDrawScope() + + drawScope.draw( + density = Density(1f), + layoutDirection = LayoutDirection.Ltr, + canvas = canvas, + size = androidx.compose.ui.geometry.Size(size.toFloat(), size.toFloat()), + ) { + val squareSize = size.toFloat() / totalModules + + // Fill background white + drawRect( + color = Color.White, + topLeft = Offset.Zero, + size = androidx.compose.ui.geometry.Size(size.toFloat(), size.toFloat()), + ) + + // Draw dark squares + for (row in 0 until matrixSize) { + for (col in 0 until matrixSize) { + if (rawMatrix[row][col].dark) { + drawRect( + color = Color.Black, + topLeft = Offset((col + quietZone) * squareSize, (row + quietZone) * squareSize), + size = Size(squareSize, squareSize), + ) + } + } + } + } + BitmapPainter(bitmap) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt new file mode 100644 index 000000000..38b7a80ef --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt @@ -0,0 +1,26 @@ +/* + * 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 . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.Composable + +/** + * A Composable that sets the screen brightness while it is in the composition. + * + * @param brightness The brightness value (0.0 to 1.0). + */ +@Composable expect fun SetScreenBrightness(brightness: Float) diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt similarity index 79% rename from core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt rename to core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt index c1b8b1108..79105a059 100644 --- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/QrUtils.kt +++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025-2026 Meshtastic LLC + * 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 @@ -17,10 +17,6 @@ package org.meshtastic.core.ui.util import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.ImageBitmap - -/** JVM stub — QR code generation not yet implemented on Desktop. */ -actual fun generateQrCode(text: String, size: Int): ImageBitmap? = null /** JVM no-op — screen brightness control is not available on Desktop. */ @Composable diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 4a8bd17ef..24b73cbc5 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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) = 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() } + + 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() } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt index d9c5a3f6b..cc0f19c09 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt @@ -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.desktopMessagingGraph(backStack: NavBackStack) { entry { val viewModel: ContactsViewModel = koinViewModel() - DesktopAdaptiveContactsScreen(viewModel = viewModel) + DesktopAdaptiveContactsScreen( + viewModel = viewModel, + onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) }, + ) } entry { val viewModel: ContactsViewModel = koinViewModel() - DesktopAdaptiveContactsScreen(viewModel = viewModel) + DesktopAdaptiveContactsScreen( + viewModel = viewModel, + onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) }, + ) } entry { route -> diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index b53d7b07e..5ec4d35f7 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -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.desktopNavGraph(backStack: NavBackStack) desktopSettingsGraph(backStack) // Channels - entry { PlaceholderScreen("Channels") } - entry { PlaceholderScreen("Channels") } + channelsGraph(backStack) // Connections — shared screen entry { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index 1a08b3f50..26ff14e5a 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -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, radioService: RadioInterfaceService = koinInject()) { +fun DesktopMainScreen( + backStack: NavBackStack, + radioService: RadioInterfaceService = koinInject(), + uiViewModel: UIViewModel = koinViewModel(), +) { val currentKey = backStack.lastOrNull() val selected = TopLevelDestination.fromNavKey(currentKey) @@ -150,6 +159,19 @@ fun DesktopMainScreen(backStack: NavBackStack, 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 { diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt index 44f75901c..f6a3433f0 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt @@ -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() 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)) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt index 9ea892ae0..249b2fcdf 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt @@ -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(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 ( diff --git a/docs/agent-playbooks/README.md b/docs/agent-playbooks/README.md index a80780f7c..375920e15 100644 --- a/docs/agent-playbooks/README.md +++ b/docs/agent-playbooks/README.md @@ -8,8 +8,8 @@ Use `AGENTS.md` as the source of truth for architecture boundaries and required When checking upstream docs/examples, match these repository-pinned versions from `gradle/libs.versions.toml`: -- Kotlin: `2.3.10` -- Koin: `4.2.0-RC2` (`koin-annotations` `2.1.0`, compiler plugin `0.4.0`) +- Kotlin: `2.3.20` +- Koin: `4.2.0` (`koin-annotations` `2.1.0`, compiler plugin `0.4.1`) - JetBrains Navigation 3: `1.1.0-alpha04` (`org.jetbrains.androidx.navigation3`) - JetBrains Lifecycle (multiplatform): `2.10.0-beta01` (`org.jetbrains.androidx.lifecycle`) - AndroidX Lifecycle (Android-only): `2.10.0` (`androidx.lifecycle`) diff --git a/docs/kmp-status.md b/docs/kmp-status.md index e1f077221..63df6b274 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -32,25 +32,25 @@ Modules that share JVM-specific code between Android and desktop now standardize | `core:ble` | ✅ | ✅ | Kable multiplatform BLE abstractions in commonMain | | `core:nfc` | ✅ | ✅ | NFC contract in commonMain; hardware in androidMain | | `core:service` | ✅ | ✅ | Service layer; Android bindings in androidMain | -| `core:ui` | ✅ | ✅ | Shared Compose UI, `jvmAndroidMain` + `jvmMain` actuals | +| `core:ui` | ✅ | ✅ | Shared Compose UI, pure KMP QR generator, `jvmAndroidMain` + `jvmMain` actuals | | `core:testing` | ✅ | ✅ | Shared test doubles, fakes, and utilities for `commonTest` | | `core:api` | ❌ | — | Android-only (AIDL). Intentional. | | `core:barcode` | ❌ | — | Android-only (CameraX). Flavor split minimised to decoder factory only (ML Kit / ZXing). Shared contract in `core:ui`. | **18/20** core modules are KMP with JVM targets. The 2 Android-only modules are intentionally platform-specific, with shared contracts already abstracted into `core:ui/commonMain`. -### Feature Modules (8 total — all KMP with JVM) +### Feature Modules (8 total — 7 KMP with JVM) | Module | UI in commonMain? | Desktop wired? | |---|:---:|:---:| -| `feature:settings` | ✅ | ✅ ~35 real screens; shared `ChannelViewModel` | +| `feature:settings` | ✅ | ✅ ~35 real screens; shared `ChannelScreen` & `ViewModel` | | `feature:node` | ✅ | ✅ Adaptive list-detail; shared `NodeContextMenu` | | `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; 17 shared files in commonMain (ViewModels, MessageBubble, MessageItem, QuickChat, Reactions, DeliveryInfo, actions, events) | | `feature:connections` | ✅ | ✅ Shared `ConnectionsScreen` with dynamic transport detection | | `feature:intro` | ✅ | — | | `feature:map` | ✅ | Placeholder; shared `NodeMapViewModel` | | `feature:firmware` | — | Placeholder; DFU is Android-only | -| `feature:widget` | ❌ | — | Android-only (App Widgets). Intentional. | +| `feature:widget` | ❌ | — | Android-only (Glance appwidgets). Intentional. | ### Desktop Module diff --git a/docs/roadmap.md b/docs/roadmap.md index dc785bc50..430f19fef 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -69,6 +69,7 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | | Connections | ✅ Unified shared UI with dynamic transport detection | | Metrics logs | ✅ TracerouteLog, NeighborInfoLog, HostMetricsLog | | Map | ❌ Needs MapLibre or equivalent | +| QR Generation | ✅ Pure KMP generation via `qrcode-kotlin` | | Charts | ✅ Vico KMP charts wired in commonMain (Device, Environment, Signal, Power, Pax) | | Debug Panel | ✅ Real screen (mesh log viewer via shared `DebugViewModel`) | | Notifications | ✅ Desktop native notifications with system tray icon support | diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/Channel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt similarity index 93% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/Channel.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt index d05b1f7b6..55ca713fe 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/Channel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt @@ -14,9 +14,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.settings.navigation +package org.meshtastic.feature.settings.radio.channel -import android.os.RemoteException import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -57,10 +56,7 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -93,9 +89,11 @@ import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.component.PreferenceFooter import org.meshtastic.core.ui.component.QrDialog import org.meshtastic.core.ui.qr.ScannedQrCodeDialog -import org.meshtastic.core.ui.util.generateQrCode -import org.meshtastic.core.ui.util.showToast +import org.meshtastic.core.ui.util.rememberQrCodePainter +import org.meshtastic.core.ui.util.rememberShowToastResource import org.meshtastic.feature.settings.channel.ChannelViewModel +import org.meshtastic.feature.settings.navigation.ConfigRoute +import org.meshtastic.feature.settings.navigation.getNavRouteFrom import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog import org.meshtastic.proto.ChannelSet @@ -167,21 +165,22 @@ fun ChannelScreen( channelSet.copy(settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true }) val scope = rememberCoroutineScope() - val context = LocalContext.current + val showToast = rememberShowToastResource() // Send new channel settings to the device + @Suppress("TooGenericExceptionCaught") fun installSettings(newChannelSet: ChannelSet) { // Try to change the radio, if it fails, tell the user why and throw away their edits try { viewModel.setChannels(newChannelSet) // Since we are writing to DeviceConfig, that will trigger the rest of the GUI update (QR code etc) - } catch (ex: RemoteException) { + } catch (ex: Exception) { Logger.e(ex) { "ignoring channel problem" } channelSet = channels // Throw away user edits // Tell the user to try again - scope.launch { context.showToast(Res.string.cant_change_no_radio) } + scope.launch { showToast(Res.string.cant_change_no_radio) } } } @@ -302,11 +301,11 @@ private const val QR_CODE_SIZE = 960 private fun ChannelShareDialog(channelSet: ChannelSet, shouldAddChannel: Boolean, onDismiss: () -> Unit) { val commonUri = channelSet.getChannelUrl(false, shouldAddChannel) val uriString = commonUri.toString() - val qrCode = remember(uriString) { generateQrCode(uriString, QR_CODE_SIZE) } + val qrPainter = rememberQrCodePainter(uriString, QR_CODE_SIZE) QrDialog( title = stringResource(Res.string.share_channels_qr), uriString = uriString, - qrCode = qrCode, + qrPainter = qrPainter, onDismiss = onDismiss, ) } @@ -385,20 +384,3 @@ private fun ModemPresetInfo(modemPresetName: String, onClick: () -> Unit) { ) } } - -@Preview(showBackground = true) -@Composable -fun ModemPresetInfoPreview() { - ModemPresetInfo(modemPresetName = "Long Fast", onClick = {}) -} - -@PreviewScreenSizes -@Composable -private fun ChannelScreenPreview() { - ChannelListView( - enabled = true, - channelSet = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig), - modemPresetName = Channel.default.name, - channelSelections = listOf(true).toMutableStateList(), - ) -} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/ChannelsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt similarity index 96% rename from feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/ChannelsNavigation.kt rename to feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt index cfda37cab..9966ca24e 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/ChannelsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelsNavigation.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.settings.navigation +package org.meshtastic.feature.settings.radio.channel import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32aafc7d9..562500d4a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ kable = "0.42.0" nordic-dfu = "2.11.0" kmqtt = "1.0.0" jmdns = "3.6.3" +qrcode-kotlin = "4.2.0" [libraries] # AndroidX @@ -165,6 +166,7 @@ mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" } wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" } zxing-core = { module = "com.google.zxing:core", version = "3.5.4" } +qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode-kotlin" } # Jetbrains kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }