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
33 changed files with 379 additions and 209 deletions

View File

@@ -24,6 +24,7 @@ jobs:
- uses: dorny/paths-filter@v4
id: filter
with:
token: ''
filters: |
android:
# CI/workflow implementation

View File

@@ -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.

View File

@@ -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.

View File

@@ -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) },

View File

@@ -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")

View File

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

View File

@@ -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<UIViewModel>()`.
- 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<String>)`.
- Resolve `UIViewModel` after `koinApp` initialization: `val uiViewModel = koinApp.koin.get<UIViewModel>()`.
- 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.

View File

@@ -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 {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
@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
}

View File

@@ -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)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}

View File

@@ -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"

View File

@@ -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,
)
}

View File

@@ -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<MenuFABItem>()
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(

View File

@@ -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,

View File

@@ -29,3 +29,5 @@ val LocalBarcodeScannerProvider =
}
}
}
val LocalBarcodeScannerSupported = compositionLocalOf { false }

View File

@@ -21,3 +21,5 @@ import androidx.compose.runtime.compositionLocalOf
val LocalNfcScannerProvider =
compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }
val LocalNfcScannerSupported = compositionLocalOf { false }

View File

@@ -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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)

View File

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

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 (

View File

@@ -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`)

View File

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

View File

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

View File

@@ -14,9 +14,8 @@
* 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.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(),
)
}

View File

@@ -14,7 +14,7 @@
* 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.feature.settings.navigation
package org.meshtastic.feature.settings.radio.channel
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack

View File

@@ -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" }