mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-28 18:52:42 -04:00
feat: Add KMP URI handling, import, and QR code generation support (#4856)
This commit is contained in:
1
.github/workflows/pull-request.yml
vendored
1
.github/workflows/pull-request.yml
vendored
@@ -24,6 +24,7 @@ jobs:
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
token: ''
|
||||
filters: |
|
||||
android:
|
||||
# CI/workflow implementation
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
31
conductor/desktop-uri-import-plan.md
Normal file
31
conductor/desktop-uri-import-plan.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,3 +29,5 @@ val LocalBarcodeScannerProvider =
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val LocalBarcodeScannerSupported = compositionLocalOf { false }
|
||||
|
||||
@@ -21,3 +21,5 @@ import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
val LocalNfcScannerProvider =
|
||||
compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }
|
||||
|
||||
val LocalNfcScannerSupported = compositionLocalOf { false }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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>() }
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user