feat: Migrate project to Kotlin Multiplatform (KMP) architecture (#4738)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-06 20:43:45 -06:00 committed by GitHub
parent 182ad933f4
commit 0ce322a0f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
163 changed files with 1837 additions and 877 deletions

163
AGENTS.md
View file

@ -1,140 +1,61 @@
# Meshtastic Android - Agent Guide
This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and workflows.
This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project.
## 1. Project Overview
- **Type:** Native Android Application (Kotlin).
- **Purpose:** Client interface for Meshtastic mesh radios.
- **Architecture:** Modern Android Development (MAD) principles.
- **UI:** Jetpack Compose (Material 3).
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
- **Dependency Injection:** Hilt.
- **Navigation:** Type-Safe Navigation (Jetpack Navigation).
- **Data Layer:** Repository pattern with Room (local DB), DataStore (prefs), and Protobuf (device comms).
## 1. Project Vision
We are incrementally migrating Meshtastic-Android to a **Kotlin Multiplatform (KMP)** architecture. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience.
## 2. Codebase Map
| Directory | Description |
| :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, `AppNavigation`, and Hilt entry points. Uses package `com.geeksville.mesh`. |
| `core/` | Shared library modules. Most code here uses package `org.meshtastic.core.*`. |
| `core/ble/` | **New:** Bluetooth Low Energy stack using Nordic libraries. |
| `core/strings/` | **Crucial:** Centralized string resources using Compose Multiplatform Resources. |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`). Each is a standalone Gradle module. Uses package `org.meshtastic.feature.*`. |
| `build-logic/` | Custom Gradle convention plugins. Defines build logic for the entire project. |
| `gradle/libs.versions.toml` | **Version Catalog.** All dependencies and versions are defined here. |
| `core/proto/` | Protobuf definitions for communicating with the mesh radio. |
| `app/` | Main application module. Contains `MainActivity`, Hilt DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `core/model` | Domain models and common data structures. |
| `core:proto` | Protobuf definitions (Git submodule). |
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
| `core:database` | Room KMP database implementation. |
| `core:datastore` | Multiplatform DataStore for preferences. |
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor and MQTT abstractions. |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core/ble/` | Bluetooth Low Energy stack using Nordic libraries. |
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`). |
## 3. Development Guidelines
### A. UI Development (Jetpack Compose)
- **Material 3:** The app uses Material 3. Look for ways to use **Material 3 Expressive** components where appropriate.
- **Material 3:** The app uses Material 3.
- **Strings:**
- Do **not** use `app/src/main/res/values/strings.xml` for UI strings.
- Use the **Compose Multiplatform Resource** library in `core:resources`.
- **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Usage:**
```kotlin
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.your_string_key
- **Rule:** MUST use the **Compose Multiplatform Resource** library in `core:resources`.
- **Location:** `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Dialogs:** Use centralized components in `core:ui`.
Text(text = stringResource(Res.string.your_string_key))
```
- **Dialogs:**
- Use the centralized `MeshtasticDialog` for all alerts and confirmation boxes.
- **Specialized Overloads:** Use `MeshtasticResourceDialog` (for resource-only content) or `MeshtasticTextDialog` (for mixed resource/text content) to reduce boilerplate.
- **Location:** Defined in `core/ui/src/main/kotlin/org/meshtastic/core/ui/component/AlertDialogs.kt`.
- **Previews:** Create `@Preview` functions for your Composables to ensure they render correctly.
### B. Logic & Data Layer
- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module.
- **I/O:** Use **Okio** (`BufferedSource`/`BufferedSink`) for stream operations. Never use `java.io` in `commonMain`.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Thread-Safety:** Use `atomicfu` and `kotlinx.collections.immutable` for shared state in `commonMain`. Avoid `synchronized` or JVM-specific atomics.
- **Dependency Injection:**
- Use **Hilt**.
- **Restriction:** Move Hilt modules to the `app` module if the library module is KMP with multiple flavors, as KSP/Hilt generation often fails in these complex scenarios.
### B. Architecture & State
- **ViewModels:** Must be annotated with `@HiltViewModel`.
- **Injection:** Use `@Inject constructor(...)`.
- **Scopes:** Use `viewModelScope` for coroutines. Avoid `GlobalScope`.
- **Data Flow:** Expose UI state as `StateFlow<UiState>` or `Flow<UiState>`.
### C. Namespacing
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID and specific intent strings for backward compatibility.
### C. Navigation
- The project uses **Type-Safe Navigation** (Kotlin Serialization).
- Routes are defined in `core/navigation` (e.g., `ContactsRoutes`, `SettingsRoutes`).
- The main `NavHost` is located in `app/src/main/java/com/geeksville/mesh/ui/Main.kt`.
## 4. Execution Protocol
### D. Bluetooth (BLE)
- **Library:** Uses **Nordic Semiconductor's Kotlin BLE Library** and **Android Common Libraries**.
- **Location:** Core logic resides in `core/ble`.
- **Key Classes:** `BluetoothRepository`, `NordicBleInterface`, `BleConnection`.
- **Usage:** Use `BluetoothRepository` for scanning and bonding. Use `BleConnection` for managing connections. Avoid legacy `BluetoothAdapter` APIs directly.
- **Environment Mocking:** Use `LocalEnvironmentOwner` and `MockAndroidEnvironment` to test UI hardware reactions without a real device.
### A. Build and Verify
1. **Format:** `./gradlew spotlessApply`
2. **Lint:** `./gradlew detekt`
3. **Test:** `./gradlew testAndroid` (or `testCommonMain` for pure logic)
### E. Dependency Management
- **Never** hardcode versions in `build.gradle.kts` files.
- **Action:** Add the library and version to `gradle/libs.versions.toml`.
- **Action:** Apply plugins using the alias from the catalog (e.g., `alias(libs.plugins.meshtastic.android.library)`).
- **Alpha Libraries:** Do not be shy about using alpha libraries from Google if they provide necessary features.
### B. Expect/Actual Patterns
Use `expect`/`actual` sparingly for platform-specific types (e.g., `Location`, `NavHostController`) to keep the core logic pure and platform-agnostic.
### F. Build Variants (Flavors)
- **`google`**: Includes Google Play Services (Maps, Firebase, Crashlytics).
- **`fdroid`**: FOSS version. **Strictly segregate sensitive data** (Crashlytics, Firebase, etc.) out of this flavor.
- **Task Example:** `./gradlew assembleFdroidDebug`
### G. Kotlin Multiplatform (KMP) & Decoupling
- **Goal:** We are actively moving logic and models from Android-specific modules to KMP modules (`core:common`, `core:model`, `core:proto`) to support future cross-platform expansion.
- **Domain Models:** Always place domain models (Data Classes, Enums) in `commonMain` of the respective module.
- **Parceling:**
- Use the platform-agnostic `CommonParcelable` and `CommonParcelize` from `core:common`.
- Avoid direct imports of `android.os.Parcelable` or `kotlinx.parcelize.Parcelize` in `commonMain`.
- **Platform Abstractions:** Use `expect`/`actual` for platform-specific logic (e.g., `DateFormatter`, `RandomUtils`, `BuildUtils`).
- **AIDL Compatibility:** AIDL parcelable declarations for models moved to `commonMain` should be relocated to `:core:api` to ensure proper export to consumer modules.
## 4. Quality Assurance
### A. Code Style (Spotless)
- The project uses **Spotless** to enforce formatting.
- **Command:** `./gradlew spotlessApply`
- **Rule:** You **must** run this before submitting any code.
### B. Linting (Detekt)
- The project uses **Detekt** for static analysis.
- **Command:** `./gradlew detekt`
- **Rule:** Ensure zero regressions.
### C. Testing
- **Unit Tests:** JUnit 4/5 in `src/test/java`. Run with `./gradlew test`.
- **Compose UI Tests (JVM):** Preferred for component testing. Use **Robolectric** in `src/test/java`.
- **Important:** Annotate with `@Config(sdk = [34])` if using Java 17 to avoid SDK 35 compatibility issues.
- **Best Practice:** Pass mocked ViewModels to Composables instead of using Hilt in Robolectric tests.
- **Instrumented Tests:** For full E2E or Hilt integration tests, use `src/androidTest/java`. Run with `./gradlew connectedAndroidTest`.
- **Feature Test:** `./gradlew feature:settings:testGoogleDebug`
## 5. Agent Workflow
1. **Explore First:** Before making changes, read `gradle/libs.versions.toml` and the relevant `build.gradle.kts` to understand the environment.
2. **Plan:** Identify which modules (`core` or `feature`) need modification.
3. **Implement:**
- If adding a string, modify `core:resources`.
- If adding a dependency, modify `libs.versions.toml` first.
4. **Verify:**
- Run `./gradlew spotlessApply` (Essential!).
- Run `./gradlew detekt`.
- Run relevant tests (e.g., `./gradlew :feature:settings:testDebugUnitTest`).
## 6. Important Context
- **Protobuf:** Communication with the device uses Protobufs. The definitions are in `core/proto`. This is a Git submodule, but the build system handles it.
- **Legacy:** Some code in `app/` uses the `com.geeksville.mesh` package. Newer code in `core/` and `feature/` uses `org.meshtastic.*`. Respect the existing package structure of the file you are editing.
- **Versioning:** Do not manually edit `versionCode` or `versionName`. These are managed by the build system and CI/CD.
- **Database Safety:** When modifying critical database logic (e.g., `NodeInfoDao`), always ensure you have explicit test coverage for security edge cases (like PKC conflicts or key wiping). Refer to `core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/NodeInfoDaoTest.kt` for examples.
## 7. Troubleshooting
- **Missing Strings:** If `Res.string.xyz` is unresolved, ensure you have imported `org.meshtastic.core.resources.Res` and the specific string property, and that you have run a build to generate the resources.
- **Build Errors:** Check `gradle/libs.versions.toml` for version conflicts. Use `build-logic` conventions to ensure plugins are applied correctly.
---
*Refer to `CONTRIBUTING.md` for human-centric processes like Code of Conduct and Pull Request etiquette.*
### E. Resources and Assets
- **Centralization:** All global app resources (Strings, Drawables, Fonts, raw files) should be placed in `:core:resources`.
- **Module Path:** `core/resources/src/commonMain/composeResources/`
- **Decentralization:** Feature-specific strings and assets can (and should) be housed in their respective feature module's `composeResources` directory to maintain modular boundaries and clean architectural dependency graphs. Crowdin localization handles globbing `/**/composeResources/values/strings.xml` perfectly.
- **Drawables:** Use `painterResource(Res.drawable.your_icon)` to access cross-platform drawables. Name them consistently (`ic_` for icons, `img_` for artwork). Avoid putting standard Drawables or Vectors in legacy Android `res/drawable` folders unless strictly required by a legacy library (like `OsmDroid` map markers) or the OS layer (like `app_icon.xml`).
## 5. Troubleshooting
- **Build Failures:** Always check `gradle/libs.versions.toml` for dependency conflicts.
- **Hilt Generation:** If `@Inject` fails in a KMP module, ensure the corresponding module is bound in the `app` layer's DI package.

View file

@ -59,15 +59,16 @@ You can generate the documentation locally to preview your changes.
## Architecture
### Modern Android Development (MAD)
The app follows modern Android development practices:
- **UI:** Jetpack Compose (Material 3).
The app follows modern Android development practices, built on top of a shared Kotlin Multiplatform (KMP) Core:
- **KMP Modules:** Business logic (`core:domain`), data sources (`core:data`, `core:database`, `core:datastore`), and communications (`core:network`, `core:ble`) are entirely platform-agnostic, enabling future support for Desktop and Web.
- **UI:** Jetpack Compose (Material 3) using Compose Multiplatform resources.
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
- **Dependency Injection:** Hilt.
- **Dependency Injection:** Hilt (mapped to KMP `javax.inject` interfaces).
- **Navigation:** Type-Safe Navigation (Jetpack Navigation).
- **Data Layer:** Repository pattern with Room (local DB), DataStore (prefs), and Protobuf (device comms).
- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms).
### Bluetooth Low Energy (BLE)
The BLE stack has been modernized to use **Nordic Semiconductor's Android Common Libraries** and **Kotlin BLE Library**. This provides a robust, Coroutine-based architecture for reliable device communication. See [core/ble/README.md](core/ble/README.md) for details.
The BLE stack uses a hybrid interface-driven architecture. Platform-agnostic interfaces live in `commonMain`, while the Android implementation utilizes **Nordic Semiconductor's Kotlin BLE Library**. This provides a robust, Coroutine-based architecture for reliable device communication while remaining KMP compatible. See [core/ble/README.md](core/ble/README.md) for details.
## Translations

View file

@ -262,9 +262,11 @@ dependencies {
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
ksp(libs.androidx.hilt.compiler)
implementation(libs.accompanist.permissions)
implementation(libs.kermit)
implementation(libs.kotlinx.datetime)
implementation(libs.nordic.client.android)
implementation(libs.nordic.common.core)
@ -278,6 +280,9 @@ dependencies {
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
googleImplementation(libs.dd.sdk.android.okhttp)
googleImplementation(libs.dd.sdk.android.compose)
googleImplementation(libs.dd.sdk.android.logs)
@ -291,6 +296,7 @@ dependencies {
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
@ -300,6 +306,7 @@ dependencies {
androidTestImplementation(libs.nordic.client.android.mock)
androidTestImplementation(libs.nordic.core.mock)
testImplementation(libs.androidx.work.testing)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)

View file

@ -15,14 +15,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.cluster;
package org.meshtastic.app.map.cluster;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Point;
import android.view.MotionEvent;
import org.meshtastic.feature.map.model.MarkerWithLabel;
import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.views.MapView;

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.cluster;
package org.meshtastic.app.map.cluster;
import android.content.Context;
import android.graphics.Bitmap;
@ -27,7 +27,7 @@ import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.MotionEvent;
import org.meshtastic.feature.map.model.MarkerWithLabel;
import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.bonuspack.R;
import org.osmdroid.util.BoundingBox;

View file

@ -15,9 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.cluster;
package org.meshtastic.app.map.cluster;
import org.meshtastic.feature.map.model.MarkerWithLabel;
import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.util.GeoPoint;

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,8 +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.intro
package org.meshtastic.app.intro
import androidx.compose.runtime.Composable

View file

@ -0,0 +1,48 @@
/*
* 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.app.map
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.meshtastic.core.ui.util.MapViewProvider
class FdroidMapViewProvider : MapViewProvider {
@Composable
override fun MapView(
modifier: Modifier,
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int?,
nodeTracks: List<Any>?,
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
) {
val mapViewModel: MapViewModel = hiltViewModel()
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
focusedNodeNum = focusedNodeNum,
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,8 +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.map
package org.meshtastic.app.map
import android.content.Context
import android.util.TypedValue

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.map
package org.meshtastic.app.map
import android.Manifest
import android.graphics.Paint
@ -83,6 +83,14 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.R
import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.component.MapButton
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
@ -131,14 +139,9 @@ import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.cluster.RadiusMarkerClusterer
import org.meshtastic.feature.map.component.CacheLayout
import org.meshtastic.feature.map.component.DownloadButton
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.model.CustomTileSource
import org.meshtastic.feature.map.model.MarkerWithLabel
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint

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.map
package org.meshtastic.app.map
import android.graphics.Color
import android.graphics.DashPathEffect
@ -24,6 +24,7 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import org.meshtastic.app.R
import org.meshtastic.proto.Position
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView

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.map
package org.meshtastic.app.map
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.toRoute
@ -31,6 +31,7 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,8 +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.map
package org.meshtastic.app.map
import android.annotation.SuppressLint
import android.content.Context

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.map
package org.meshtastic.app.map
import android.database.Cursor
import org.meshtastic.core.common.util.nowMillis

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing

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.map.component
package org.meshtastic.app.map.component
import android.app.DatePickerDialog
import android.widget.DatePicker
@ -66,10 +66,8 @@ import kotlinx.datetime.Month
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
@ -121,7 +119,7 @@ fun EditWaypointDialog(
if (expire != 0 && expire != Int.MAX_VALUE) {
Instant.fromEpochSeconds(expire.toLong())
} else {
nowInstant + 8.hours
kotlinx.datetime.Clock.System.now() + 8.hours
}
}
@ -130,7 +128,7 @@ fun EditWaypointDialog(
remember(currentInstant) {
mutableStateOf(
if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
dateFormat.format(currentInstant.toDate())
dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},
@ -140,7 +138,7 @@ fun EditWaypointDialog(
remember(currentInstant) {
mutableStateOf(
if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
timeFormat.format(currentInstant.toDate())
timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,8 +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.map.model
package org.meshtastic.app.map.model
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,16 +14,15 @@
* 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.map.model
package org.meshtastic.app.map.model
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.MotionEvent
import org.meshtastic.feature.map.dpToPx
import org.meshtastic.feature.map.spToPx
import org.meshtastic.app.map.dpToPx
import org.meshtastic.app.map.spToPx
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon

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.map.model
package org.meshtastic.app.map.model
import android.content.res.Resources
import co.touchlab.kermit.Logger

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,8 +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.map.model
package org.meshtastic.app.map.model
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy

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.map.node
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@ -24,11 +24,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.feature.map.addCopyright
import org.meshtastic.feature.map.addPolyline
import org.meshtastic.feature.map.addPositionMarkers
import org.meshtastic.feature.map.addScaleBarOverlay
import org.meshtastic.feature.map.rememberMapViewWithLifecycle
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addPolyline
import org.meshtastic.app.map.addPositionMarkers
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
@ -44,7 +45,7 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
rememberMapViewWithLifecycle(
applicationId = nodeMapViewModel.applicationId,
box = cameraView,
tileSource = nodeMapViewModel.tileSource,
tileSource = CustomTileSource.getTileSource(nodeMapViewModel.mapStyleId),
)
AndroidView(

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.intro
package org.meshtastic.app.intro
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column

View file

@ -0,0 +1,21 @@
/*
* 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.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()

View file

@ -0,0 +1,48 @@
/*
* 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.app.map
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.meshtastic.core.ui.util.MapViewProvider
class GoogleMapViewProvider : MapViewProvider {
@Composable
override fun MapView(
modifier: Modifier,
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int?,
nodeTracks: List<Any>?,
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
) {
val mapViewModel: MapViewModel = hiltViewModel()
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
focusedNodeNum = focusedNodeNum,
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,8 +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.map
package org.meshtastic.app.map
import android.Manifest
import android.app.Activity

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.map
package org.meshtastic.app.map
import android.database.sqlite.SQLiteDatabase
import com.google.android.gms.maps.model.Tile

View file

@ -16,7 +16,7 @@
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.map
package org.meshtastic.app.map
import android.Manifest
import android.app.Activity
@ -95,6 +95,14 @@ import com.google.maps.android.data.kml.KmlLayer
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.json.JSONObject
import org.meshtastic.app.map.component.ClusterItemsListDialog
import org.meshtastic.app.map.component.CustomMapLayersSheet
import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.component.MapControlsOverlay
import org.meshtastic.app.map.component.NodeClusterMarkers
import org.meshtastic.app.map.component.WaypointMarkers
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
@ -116,15 +124,9 @@ import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.feature.map.component.ClusterItemsListDialog
import org.meshtastic.feature.map.component.CustomMapLayersSheet
import org.meshtastic.feature.map.component.CustomTileProviderManagerSheet
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.NodeClusterMarkers
import org.meshtastic.feature.map.component.WaypointMarkers
import org.meshtastic.feature.map.model.NodeClusterItem
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint

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.map
package org.meshtastic.app.map
import android.app.Application
import android.net.Uri
@ -43,6 +43,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
@ -51,9 +54,7 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.CustomTileProviderConfig
import org.meshtastic.feature.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.feature.map.repository.CustomTileProviderRepository
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Config
import java.io.File
import java.io.FileOutputStream

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
@ -30,11 +30,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.nodes_at_this_location
import org.meshtastic.core.resources.okay
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.feature.map.model.NodeClusterItem
@Composable
fun ClusterItemsListDialog(

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -49,6 +49,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapLayerItem
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_layer
import org.meshtastic.core.resources.add_network_layer
@ -65,7 +66,6 @@ import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.show_layer
import org.meshtastic.core.resources.url
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.map.MapLayerItem
@Suppress("LongMethod")
@Composable

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.map.component
package org.meshtastic.app.map.component
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
@ -51,6 +51,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_custom_tile_source
import org.meshtastic.core.resources.add_local_mbtiles_file
@ -70,8 +72,6 @@ import org.meshtastic.core.resources.url_template
import org.meshtastic.core.resources.url_template_hint
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.MapViewModel
import org.meshtastic.feature.map.model.CustomTileProviderConfig
@Suppress("LongMethod")
@Composable

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.map.component
package org.meshtastic.app.map.component
import android.app.DatePickerDialog
import android.app.TimePickerDialog
@ -68,9 +68,7 @@ import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
@ -123,12 +121,12 @@ fun EditWaypointDialog(
if (isExpiryEnabled) {
if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
val instant = Instant.fromEpochSeconds(expireValue.toLong())
val date = instant.toDate()
val date = java.util.Date(instant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
} else { // If enabled but not set, default to 8 hours from now
val futureInstant = nowInstant + 8.hours
val date = futureInstant.toDate()
val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours
val date = java.util.Date(futureInstant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
@ -225,7 +223,7 @@ fun EditWaypointDialog(
val expireValue = waypointInput.expire ?: 0
// Default to 8 hours from now if not already set
if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
val futureInstant = nowInstant + 8.hours
val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
}
} else {
@ -241,7 +239,7 @@ fun EditWaypointDialog(
if (it != 0 && it != Int.MAX_VALUE) {
Instant.fromEpochSeconds(it.toLong())
} else {
nowInstant + 8.hours
kotlinx.datetime.Clock.System.now() + 8.hours
}
}
val ldt = currentInstant.toLocalDateTime(tz)
@ -256,7 +254,7 @@ fun EditWaypointDialog(
if (it != 0 && it != Int.MAX_VALUE) {
Instant.fromEpochSeconds(it.toLong())
} else {
nowInstant + 8.hours
kotlinx.datetime.Clock.System.now() + 8.hours
}
}
.toLocalDateTime(tz)
@ -291,7 +289,7 @@ fun EditWaypointDialog(
if (it != 0 && it != Int.MAX_VALUE) {
Instant.fromEpochSeconds(it.toLong())
} else {
nowInstant + 8.hours
kotlinx.datetime.Clock.System.now() + 8.hours
}
}
.toLocalDateTime(tz)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,8 +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.map.component
package org.meshtastic.app.map.component
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_filter
@ -45,7 +46,6 @@ import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.map.MapViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@ -39,13 +39,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.MapViewModel
import kotlin.math.roundToInt
@Composable

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.maps.android.compose.MapType
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_custom_tile_sources
import org.meshtastic.core.resources.map_type_hybrid
@ -35,7 +36,6 @@ import org.meshtastic.core.resources.map_type_normal
import org.meshtastic.core.resources.map_type_satellite
import org.meshtastic.core.resources.map_type_terrain
import org.meshtastic.core.resources.selected_map_type
import org.meshtastic.feature.map.MapViewModel
@Suppress("LongMethod")
@Composable

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -32,8 +32,8 @@ import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.feature.map.model.NodeClusterItem
@OptIn(MapsComposeExperimentalApi::class)
@Suppress("NestedBlockDepth")

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing

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.map.component
package org.meshtastic.app.map.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect

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.map.model
package org.meshtastic.app.map.model
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,8 +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.map.model
package org.meshtastic.app.map.model
class CustomTileSource {

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.map.model
package org.meshtastic.app.map.model
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem

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.map.node
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
@ -23,8 +23,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.MapView
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {

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.map.prefs.di
package org.meshtastic.app.map.prefs.di
import android.content.Context
import androidx.datastore.core.DataStore
@ -31,10 +31,10 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.feature.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.feature.map.prefs.map.GoogleMapsPrefsImpl
import org.meshtastic.feature.map.repository.CustomTileProviderRepository
import org.meshtastic.feature.map.repository.CustomTileProviderRepositoryImpl
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefsImpl
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.app.map.repository.CustomTileProviderRepositoryImpl
import javax.inject.Qualifier
import javax.inject.Singleton

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.map.prefs.map
package org.meshtastic.app.map.prefs.map
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
@ -31,8 +31,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.app.map.prefs.di.GoogleMapsDataStore
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.feature.map.prefs.di.GoogleMapsDataStore
import javax.inject.Inject
import javax.inject.Singleton

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.map.repository
package org.meshtastic.app.map.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
@ -23,9 +23,9 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MapTileProviderPrefs
import org.meshtastic.feature.map.model.CustomTileProviderConfig
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -40,6 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
@ -47,14 +48,23 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.intro.AndroidIntroViewModel
import org.meshtastic.app.map.getMapViewProvider
import org.meshtastic.app.model.UIViewModel
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
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.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.intro.AppIntroductionScreen
import javax.inject.Inject
@ -108,7 +118,13 @@ class MainActivity : ComponentActivity() {
}
@Suppress("SpreadOperator")
CompositionLocalProvider(*(LocalEnvironmentOwner provides androidEnvironment)) {
CompositionLocalProvider(
*(LocalEnvironmentOwner provides androidEnvironment),
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
) {
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
@ -119,7 +135,8 @@ class MainActivity : ComponentActivity() {
if (appIntroCompleted) {
MainScreen(uIViewModel = model)
} else {
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
val introViewModel = hiltViewModel<AndroidIntroViewModel>()
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
}
}
}

View file

@ -0,0 +1,75 @@
/*
* 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.app.di
import android.content.Context
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
import org.meshtastic.core.ble.AndroidBleConnectionFactory
import org.meshtastic.core.ble.AndroidBleScanner
import org.meshtastic.core.ble.AndroidBluetoothRepository
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class BleModule {
@Binds @Singleton
abstract fun bindBleScanner(impl: AndroidBleScanner): BleScanner
@Binds @Singleton
abstract fun bindBluetoothRepository(impl: AndroidBluetoothRepository): BluetoothRepository
@Binds @Singleton
abstract fun bindBleConnectionFactory(impl: AndroidBleConnectionFactory): BleConnectionFactory
companion object {
@Provides
@Singleton
fun provideAndroidEnvironment(@ApplicationContext context: Context): AndroidEnvironment =
NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)
@Provides
@Singleton
fun provideBleSingletonCoroutineScope(dispatchers: CoroutineDispatchers): CoroutineScope =
CoroutineScope(SupervisorJob() + dispatchers.default)
@Provides
@Singleton
fun provideCentralManager(environment: AndroidEnvironment, coroutineScope: CoroutineScope): CentralManager =
CentralManager.native(environment as NativeAndroidEnvironment, coroutineScope)
@Provides
fun provideBleConnection(factory: BleConnectionFactory, coroutineScope: CoroutineScope): BleConnection =
factory.create(coroutineScope, "BLE")
}
}

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.core.service.di
package org.meshtastic.app.di
import dagger.Binds
import dagger.Module

View file

@ -129,7 +129,7 @@ constructor(
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
val suffix = entry.device.getMeshtasticShortName()?.lowercase(Locale.ROOT)
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {

View file

@ -0,0 +1,24 @@
/*
* 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.app.intro
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.feature.intro.IntroViewModel
import javax.inject.Inject
/** Android-specific Hilt wrapper for IntroViewModel. */
@HiltViewModel class AndroidIntroViewModel @Inject constructor() : IntroViewModel()

View file

@ -0,0 +1,35 @@
/*
* 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.app.map
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.feature.map.SharedMapViewModel
import javax.inject.Inject
@HiltViewModel
class AndroidSharedMapViewModel
@Inject
constructor(
mapPrefs: MapPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
) : SharedMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController)

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.map.node
package org.meshtastic.app.map.node
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
@ -35,7 +35,6 @@ import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.CustomTileSource
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import javax.inject.Inject
@ -80,6 +79,6 @@ constructor(
}
.stateInWhileSubscribed(initialValue = emptyList())
val tileSource
get() = CustomTileSource.getTileSource(mapPrefs.mapStyle.value)
val mapStyleId: Int
get() = mapPrefs.mapStyle.value
}

View file

@ -0,0 +1,35 @@
/*
* 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.app.messaging
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
import javax.inject.Inject
@HiltViewModel
class AndroidContactsViewModel
@Inject
constructor(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
) : ContactsViewModel(nodeRepository, packetRepository, radioConfigRepository, serviceRepository)

View file

@ -0,0 +1,62 @@
/*
* 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.app.messaging
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.feature.messaging.MessageViewModel
import javax.inject.Inject
@Suppress("LongParameterList")
@HiltViewModel
class AndroidMessageViewModel
@Inject
constructor(
savedStateHandle: SavedStateHandle,
nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
quickChatActionRepository: QuickChatActionRepository,
serviceRepository: ServiceRepository,
packetRepository: PacketRepository,
uiPrefs: UiPrefs,
customEmojiPrefs: CustomEmojiPrefs,
homoglyphEncodingPrefs: HomoglyphPrefs,
meshServiceNotifications: MeshServiceNotifications,
sendMessageUseCase: SendMessageUseCase,
) : MessageViewModel(
savedStateHandle,
nodeRepository,
radioConfigRepository,
quickChatActionRepository,
serviceRepository,
packetRepository,
uiPrefs,
customEmojiPrefs,
homoglyphEncodingPrefs,
meshServiceNotifications,
sendMessageUseCase,
)

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.app.messaging
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.core.data.repository.QuickChatActionRepository
import org.meshtastic.feature.messaging.QuickChatViewModel
import javax.inject.Inject
@HiltViewModel
class AndroidQuickChatViewModel @Inject constructor(quickChatActionRepository: QuickChatActionRepository) :
QuickChatViewModel(quickChatActionRepository)

View file

@ -14,14 +14,14 @@
* 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.messaging.di
package org.meshtastic.app.messaging.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.app.messaging.domain.worker.WorkManagerMessageQueue
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.feature.messaging.domain.worker.WorkManagerMessageQueue
@Module
@InstallIn(SingletonComponent::class)

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.messaging.domain.worker
package org.meshtastic.app.messaging.domain.worker
import android.content.Context
import androidx.hilt.work.HiltWorker

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.messaging.domain.worker
package org.meshtastic.app.messaging.domain.worker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder

View file

@ -18,8 +18,7 @@ package org.meshtastic.app.model
import android.hardware.usb.UsbManager
import com.hoho.android.usbserial.driver.UsbSerialDriver
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.Node
@ -50,15 +49,14 @@ sealed class DeviceListEntry(
override fun toString(): String =
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
@Suppress("MissingPermission")
data class Ble(val peripheral: Peripheral, override val node: Node? = null) :
data class Ble(val device: BleDevice, override val node: Node? = null) :
DeviceListEntry(
name = peripheral.name ?: "unnamed-${peripheral.address}",
fullAddress = "x${peripheral.address}",
bonded = peripheral.bondState.value == BondState.BONDED,
name = device.name ?: "unnamed-${device.address}",
fullAddress = "x${device.address}",
bonded = device.isBonded,
node = node,
) {
override fun copy(node: Node?): Ble = copy(peripheral = peripheral, node = node)
override fun copy(node: Node?): Ble = copy(device = device, node = node)
}
data class Usb(
@ -95,4 +93,4 @@ private val bleNameRegex = Regex(BLE_NAME_PATTERN)
*
* @return The short name (e.g., 1234) or null.
*/
fun Peripheral.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }
fun BleDevice.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }

View file

@ -26,6 +26,9 @@ import androidx.navigation.navDeepLink
import androidx.navigation.navigation
import androidx.navigation.toRoute
import kotlinx.coroutines.flow.Flow
import org.meshtastic.app.messaging.AndroidContactsViewModel
import org.meshtastic.app.messaging.AndroidMessageViewModel
import org.meshtastic.app.messaging.AndroidQuickChatViewModel
import org.meshtastic.app.model.UIViewModel
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
@ -43,9 +46,13 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
val uiViewModel: UIViewModel = hiltViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = hiltViewModel<AndroidContactsViewModel>()
val messageViewModel = hiltViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
navController = navController,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
@ -67,9 +74,13 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
val uiViewModel: UIViewModel = hiltViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = hiltViewModel<AndroidContactsViewModel>()
val messageViewModel = hiltViewModel<AndroidMessageViewModel>()
AdaptiveContactsScreen(
navController = navController,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
@ -90,7 +101,9 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
),
) { backStackEntry ->
val message = backStackEntry.toRoute<ContactsRoutes.Share>().message
val viewModel = hiltViewModel<AndroidContactsViewModel>()
ShareScreen(
viewModel = viewModel,
onConfirm = {
navController.navigate(ContactsRoutes.Messages(it, message)) {
popUpTo<ContactsRoutes.Share> { inclusive = true }
@ -102,6 +115,7 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopE
composable<ContactsRoutes.QuickChat>(
deepLinks = listOf(navDeepLink<ContactsRoutes.QuickChat>(basePath = "$DEEP_LINK_BASE_URI/quick_chat")),
) {
QuickChatScreen(onNavigateUp = navController::navigateUp)
val viewModel = hiltViewModel<AndroidQuickChatViewModel>()
QuickChatScreen(viewModel = viewModel, onNavigateUp = navController::navigateUp)
}
}

View file

@ -16,10 +16,12 @@
*/
package org.meshtastic.app.navigation
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navDeepLink
import org.meshtastic.app.map.AndroidSharedMapViewModel
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodesRoutes
@ -27,7 +29,9 @@ import org.meshtastic.feature.map.MapScreen
fun NavGraphBuilder.mapGraph(navController: NavHostController) {
composable<MapRoutes.Map>(deepLinks = listOf(navDeepLink<MapRoutes.Map>(basePath = "$DEEP_LINK_BASE_URI/map"))) {
val viewModel = hiltViewModel<AndroidSharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true

View file

@ -40,6 +40,8 @@ import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.app.map.node.NodeMapScreen
import org.meshtastic.app.map.node.NodeMapViewModel
import org.meshtastic.app.ui.node.AdaptiveNodeListScreen
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
@ -57,8 +59,6 @@ import org.meshtastic.core.resources.power
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen

View file

@ -33,12 +33,15 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import org.meshtastic.core.ble.AndroidBleDevice
import org.meshtastic.core.ble.AndroidBleService
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.retryBleOperation
import org.meshtastic.core.common.util.nowMillis
@ -62,7 +65,9 @@ private val SCAN_TIMEOUT = 5.seconds
* - Routing raw byte packets between the radio and [RadioInterfaceService].
*
* @param serviceScope The coroutine scope to use for launching coroutines.
* @param centralManager The central manager provided by Nordic BLE Library.
* @param scanner The BLE scanner.
* @param bluetoothRepository The Bluetooth repository.
* @param connectionFactory The BLE connection factory.
* @param service The [RadioInterfaceService] to use for handling radio events.
* @param address The BLE address of the device to connect to.
*/
@ -71,7 +76,9 @@ class NordicBleInterface
@AssistedInject
constructor(
private val serviceScope: CoroutineScope,
private val centralManager: CentralManager,
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
private val service: RadioInterfaceService,
@Assisted val address: String,
) : IRadioInterface {
@ -91,7 +98,7 @@ constructor(
private val connectionScope: CoroutineScope =
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
private val bleConnection: BleConnection = BleConnection(centralManager, connectionScope, address)
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
private val writeMutex: Mutex = Mutex()
private var connectionStartTime: Long = 0
@ -106,21 +113,19 @@ constructor(
// --- Connection & Discovery Logic ---
/** Robustly finds the peripheral. First checks bonded devices, then performs a short scan if not found. */
private suspend fun findPeripheral(): Peripheral {
centralManager
.getBondedPeripherals()
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
private suspend fun findDevice(): BleDevice {
bluetoothRepository.state.value.bondedDevices
.firstOrNull { it.address == address }
?.let {
return it
}
Logger.i { "[$address] Device not found in bonded list, scanning..." }
val scanner = BleScanner(centralManager)
repeat(SCAN_RETRY_COUNT) { attempt ->
val p = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
if (p != null) return p
val d = scanner.scan(SCAN_TIMEOUT).firstOrNull { it.address == address }
if (d != null) return d
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY_MS)
@ -138,7 +143,7 @@ constructor(
bleConnection.connectionState
.onEach { state ->
if (state is ConnectionState.Disconnected) {
if (state is BleConnectionState.Disconnected) {
onDisconnected(state)
}
}
@ -148,9 +153,9 @@ constructor(
}
.launchIn(connectionScope)
val p = findPeripheral()
val state = bleConnection.connectAndAwait(p, CONNECTION_TIMEOUT_MS)
if (state !is ConnectionState.Connected) {
val device = findDevice()
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
@ -158,7 +163,7 @@ constructor(
discoverServicesAndSetupCharacteristics()
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
handleFailure(e)
}
}
@ -166,8 +171,9 @@ constructor(
private suspend fun onConnected() {
try {
bleConnection.peripheralFlow.first()?.let { p ->
val rssi = retryBleOperation(tag = address) { p.readRssi() }
bleConnection.deviceFlow.first()?.let { device ->
val androidDevice = device as AndroidBleDevice
val rssi = retryBleOperation(tag = address) { androidDevice.peripheral.readRssi() }
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
}
} catch (e: Exception) {
@ -175,7 +181,7 @@ constructor(
}
}
private fun onDisconnected(state: ConnectionState.Disconnected) {
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
radioService = null
val uptime =
@ -185,26 +191,22 @@ constructor(
0
}
Logger.w {
"[$address] BLE disconnected - Reason: ${state.reason}, " +
"[$address] BLE disconnected, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
val (isPermanent, msg) =
when (val reason = state.reason) {
is ConnectionState.Disconnected.Reason.InsufficientAuthentication ->
Pair(true, "Insufficient authentication: please unpair and repair the device")
is ConnectionState.Disconnected.Reason.RequiredServiceNotFound ->
Pair(false, "Required characteristic missing")
else -> Pair(false, reason.toString())
}
service.onDisconnect(isPermanent, errorMessage = msg)
// Note: Disconnected state in commonMain doesn't currently carry a reason.
// We might want to add that later if needed.
service.onDisconnect(false, errorMessage = "Disconnected")
}
private suspend fun discoverServicesAndSetupCharacteristics() {
try {
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
val radioService = MeshtasticRadioServiceImpl(service)
val androidService = (service as AndroidBleService).service
val radioService = MeshtasticRadioServiceImpl(androidService)
// Wire up notifications
radioService.fromRadio
@ -235,7 +237,7 @@ constructor(
Logger.i { "[$address] Profile service active and characteristics subscribed" }
// Log negotiated MTU for diagnostics
val maxLen = bleConnection.maximumWriteValueLength(WriteType.WITHOUT_RESPONSE)
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
this@NordicBleInterface.service.onConnect()

View file

@ -20,8 +20,8 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.meshtastic.app.messaging.domain.worker.SendMessageWorker
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -117,15 +117,15 @@ constructor(
/** Initiates the bonding process and connects to the device upon success. */
private fun requestBonding(entry: DeviceListEntry.Ble) {
Logger.i { "Starting bonding for ${entry.peripheral.address.anonymize}" }
Logger.i { "Starting bonding for ${entry.device.address.anonymize}" }
viewModelScope.launch {
@Suppress("TooGenericExceptionCaught")
try {
bluetoothRepository.bond(entry.peripheral)
Logger.i { "Bonding complete for ${entry.peripheral.address.anonymize}, selecting device..." }
bluetoothRepository.bond(entry.device)
Logger.i { "Bonding complete for ${entry.device.address.anonymize}, selecting device..." }
changeDeviceAddress(entry.fullAddress)
} catch (ex: SecurityException) {
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" }
Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize} Permissions not granted" }
serviceRepository.setErrorMessage(
text = "Bonding failed: ${ex.message} Permissions not granted",
severity = Severity.Warn,
@ -135,9 +135,9 @@ constructor(
val message = ex.message ?: ""
if (message.contains("Received bond state changed 11")) {
// This is a known issue where bonding is still in progress, ignore as error
Logger.d { "Bonding still in progress for ${entry.peripheral.address.anonymize}" }
Logger.d { "Bonding still in progress for ${entry.device.address.anonymize}" }
} else {
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" }
Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize}" }
serviceRepository.setErrorMessage(text = "Bonding failed: ${ex.message}", severity = Severity.Warn)
}
}

View file

@ -35,6 +35,7 @@ import no.nordicsemi.android.common.scanner.view.ScannerView
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.model.DeviceListEntry
import org.meshtastic.app.ui.connections.ScannerViewModel
import org.meshtastic.core.ble.AndroidBleDevice
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.model.ConnectionState
@ -73,12 +74,14 @@ fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanMod
ScannerView(
state = filterState,
onScanResultSelected = { result -> scanModel.onSelected(DeviceListEntry.Ble(result.peripheral)) },
onScanResultSelected = { result ->
scanModel.onSelected(DeviceListEntry.Ble(AndroidBleDevice(result.peripheral)))
},
deviceItem = { result ->
val device =
remember(result.peripheral.address, bleDevices) {
bleDevices.find { it.fullAddress == "x${result.peripheral.address}" }
?: DeviceListEntry.Ble(result.peripheral)
?: DeviceListEntry.Ble(AndroidBleDevice(result.peripheral))
}
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),

View file

@ -43,8 +43,6 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.ui.view.RssiIcon
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.model.DeviceListEntry
import org.meshtastic.core.model.Node
@ -75,23 +73,14 @@ fun CurrentlyConnectedInfo(
var rssi by remember { mutableIntStateOf(0) }
LaunchedEffect(bleDevice) {
if (bleDevice != null) {
while (bleDevice.peripheral.isConnected) {
while (bleDevice.device.isConnected) {
try {
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.peripheral.readRssi() }
rssi = withTimeout(RSSI_TIMEOUT.seconds) { bleDevice.device.readRssi() }
delay(RSSI_DELAY.seconds)
} catch (e: PeripheralNotConnectedException) {
Logger.w(e) { "Failed to read RSSI ${e.message}" }
break
} catch (e: OperationFailedException) {
} catch (e: Exception) {
// RSSI reading failures are common when disconnecting; log as warning to avoid Crashlytics noise
Logger.w(e) { "Failed to read RSSI ${e.message}" }
break
} catch (e: SecurityException) {
Logger.w(e) { "Failed to read RSSI ${e.message}" }
break
} catch (e: Exception) {
Logger.w(e) { "Unexpected error reading RSSI: ${e.message}" }
break
}
}
}

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.messaging.domain.worker
package org.meshtastic.app.messaging.domain.worker
import android.content.Context
import androidx.test.core.app.ApplicationProvider

View file

@ -141,10 +141,25 @@ class NordicBleInterfaceRetryTest {
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
@ -246,10 +261,25 @@ class NordicBleInterfaceRetryTest {
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = uniqueAddress,
)

View file

@ -151,10 +151,25 @@ class NordicBleInterfaceTest {
// Create the interface
println("Creating NordicBleInterface")
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
@ -284,10 +299,25 @@ class NordicBleInterfaceTest {
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
@ -377,10 +407,25 @@ class NordicBleInterfaceTest {
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
@ -467,10 +512,25 @@ class NordicBleInterfaceTest {
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)
@ -553,10 +613,25 @@ class NordicBleInterfaceTest {
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = uniqueAddress,
)
@ -644,10 +719,25 @@ class NordicBleInterfaceTest {
centralManager.simulatePeripherals(listOf(peripheralSpec))
advanceUntilIdle()
val scanner = org.meshtastic.core.ble.AndroidBleScanner(centralManager)
val bluetoothRepository: org.meshtastic.core.ble.BluetoothRepository = mockk {
io.mockk.every { state } returns
kotlinx.coroutines.flow.MutableStateFlow(
org.meshtastic.core.ble.BluetoothState(
hasPermissions = true,
enabled = true,
bondedDevices = emptyList(),
),
)
}
val connectionFactory = org.meshtastic.core.ble.AndroidBleConnectionFactory(centralManager)
val nordicInterface =
NordicBleInterface(
serviceScope = this,
centralManager = centralManager,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address,
)

View file

@ -19,13 +19,13 @@
# These need to be set separately because properties are not passed to included builds.
# https://github.com/gradle/gradle/issues/2534
org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC -Dfile.encoding=UTF-8
# Parallelism & Caching
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.isolated-projects=false
org.gradle.isolated-projects=true
org.gradle.vfs.watch=true
org.gradle.configureondemand=false

View file

@ -30,6 +30,7 @@ configure<LibraryExtension> {
dependencies {
implementation(project(":core:resources"))
implementation(projects.core.ui)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)

View file

@ -66,6 +66,7 @@ import com.google.zxing.common.HybridBinarizer
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.ui.util.BarcodeScanner
import java.nio.ByteBuffer
import java.util.concurrent.Executors

View file

@ -66,6 +66,7 @@ import com.google.mlkit.vision.common.InputImage
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.ui.util.BarcodeScanner
import java.util.concurrent.Executors
@Composable

View file

@ -15,37 +15,55 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.devtools.ksp)
}
configure<LibraryExtension> { namespace = "org.meshtastic.core.ble" }
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.ble"
androidResources.enable = false
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)
api(libs.nordic.client.android)
api(libs.nordic.ble.env.android)
api(libs.nordic.ble.env.android.compose)
api(libs.nordic.common.scanner.ble)
api(libs.nordic.common.core)
implementation(libs.kermit)
implementation(libs.kotlinx.coroutines.core)
api(libs.javax.inject)
}
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.javax.inject)
implementation(libs.kermit)
implementation(libs.kotlinx.coroutines.core)
androidMain.dependencies {
implementation(libs.hilt.android)
api(libs.nordic.client.android)
api(libs.nordic.ble.env.android)
api(libs.nordic.ble.env.android.compose)
api(libs.nordic.common.scanner.ble)
api(libs.nordic.common.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.nordic.client.android.mock)
testImplementation(libs.nordic.client.core.mock)
testImplementation(libs.nordic.core.mock)
testImplementation(libs.androidx.lifecycle.testing)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.mockk)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.nordic.client.android.mock)
implementation(libs.nordic.client.core.mock)
implementation(libs.nordic.core.mock)
implementation(libs.androidx.lifecycle.testing)
}
}
}
dependencies { add("kspAndroid", libs.hilt.compiler) }

View file

@ -34,92 +34,85 @@ import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
/**
* Encapsulates a BLE connection to a [Peripheral]. Handles connection lifecycle, state monitoring, and service
* discovery.
* An Android implementation of [BleConnection] using Nordic's [CentralManager].
*
* @param centralManager The Nordic [CentralManager] to use for connection.
* @param scope The [CoroutineScope] in which to monitor connection state.
* @param tag A tag for logging.
*/
class BleConnection(
class AndroidBleConnection(
private val centralManager: CentralManager,
private val scope: CoroutineScope,
private val tag: String = "BLE",
) {
/** The currently connected [Peripheral], or null if not connected. */
var peripheral: Peripheral? = null
private set
) : BleConnection {
private val _peripheral = MutableSharedFlow<Peripheral?>(replay = 1)
private var _device: AndroidBleDevice? = null
override val device: BleDevice?
get() = _device
/** A flow of the current peripheral. */
val peripheralFlow = _peripheral.asSharedFlow()
private val _deviceFlow = MutableSharedFlow<BleDevice?>(replay = 1)
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
private val _connectionState = simpleSharedFlow<ConnectionState>()
/** A flow of [ConnectionState] changes for the current [peripheral]. */
val connectionState: SharedFlow<ConnectionState> = _connectionState.asSharedFlow()
private val _connectionState = simpleSharedFlow<BleConnectionState>()
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
private var stateJob: Job? = null
private var profileJob: Job? = null
/**
* Connects to the given [Peripheral]. Note that this method returns as soon as the connection attempt is initiated.
* Use [connectAndAwait] if you need to wait for the connection to be established.
*
* @param p The peripheral to connect to.
*/
suspend fun connect(p: Peripheral) = withContext(NonCancellable) {
override suspend fun connect(device: BleDevice) = withContext(NonCancellable) {
val androidDevice = device as AndroidBleDevice
stateJob?.cancel()
peripheral = p
_peripheral.emit(p)
_device = androidDevice
_deviceFlow.emit(androidDevice)
centralManager.connect(
peripheral = p,
peripheral = androidDevice.peripheral,
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
)
stateJob =
p.state
androidDevice.peripheral.state
.onEach { state ->
Logger.d { "[$tag] Connection state changed to $state" }
val commonState =
when (state) {
is ConnectionState.Connecting -> BleConnectionState.Connecting
is ConnectionState.Connected -> BleConnectionState.Connected
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
}
if (state is ConnectionState.Connected) {
p.requestConnectionPriority(ConnectionPriority.HIGH)
observePeripheralDetails(p)
androidDevice.peripheral.requestConnectionPriority(ConnectionPriority.HIGH)
observePeripheralDetails(androidDevice)
}
_connectionState.emit(state)
androidDevice.updateState(state)
_connectionState.emit(commonState)
}
.launchIn(scope)
}
/**
* Connects to the given [Peripheral] and waits for a terminal state (Connected or Disconnected).
*
* @param p The peripheral to connect to.
* @param timeoutMs The maximum time to wait for a connection in milliseconds.
* @param onRegister Optional block to run before connecting, allowing for profile registration.
* @return The final [ConnectionState].
* @throws kotlinx.coroutines.TimeoutCancellationException if the timeout is reached.
*/
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long, onRegister: suspend () -> Unit = {}): ConnectionState {
override suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit,
): BleConnectionState {
onRegister()
connect(p)
connect(device)
return withTimeout(timeoutMs) {
connectionState.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
connectionState.first { it is BleConnectionState.Connected || it is BleConnectionState.Disconnected }
}
}
@Suppress("TooGenericExceptionCaught")
private fun observePeripheralDetails(p: Peripheral) {
private fun observePeripheralDetails(androidDevice: AndroidBleDevice) {
val p = androidDevice.peripheral
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
p.connectionParameters
@ -135,32 +128,24 @@ class BleConnection(
.launchIn(scope)
}
/** Disconnects from the current peripheral. */
suspend fun disconnect() = withContext(NonCancellable) {
override suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
profileJob?.cancel()
profileJob = null
peripheral?.disconnect()
peripheral = null
_peripheral.emit(null)
_device?.peripheral?.disconnect()
_device = null
_deviceFlow.emit(null)
}
/**
* Executes a block within a discovered profile. Handles peripheral readiness, discovery with a timeout, and cleans
* up the profile job if discovery fails.
*
* @param serviceUuid The UUID of the service to discover.
* @param timeout The duration to wait for discovery.
* @param block The block to execute with the discovered service.
*/
@Suppress("TooGenericExceptionCaught")
suspend fun <T> profile(
override suspend fun <T> profile(
serviceUuid: Uuid,
timeout: kotlin.time.Duration = 30.seconds,
setup: suspend CoroutineScope.(no.nordicsemi.kotlin.ble.client.RemoteService) -> T,
timeout: kotlin.time.Duration,
setup: suspend CoroutineScope.(BleService) -> T,
): T {
val p = peripheralFlow.first { it != null }!!
val androidDevice = deviceFlow.first { it != null } as AndroidBleDevice
val p = androidDevice.peripheral
val serviceReady = CompletableDeferred<T>()
profileJob?.cancel()
@ -170,9 +155,8 @@ class BleConnection(
val profileScope = this
p.profile(serviceUuid = serviceUuid, required = true, scope = profileScope) { service ->
try {
val result = setup(service)
val result = setup(AndroidBleService(service))
serviceReady.complete(result)
// Keep the profile active until this launch scope (profileJob) is cancelled
awaitCancellation()
} catch (e: Throwable) {
if (!serviceReady.isCompleted) serviceReady.completeExceptionally(e)
@ -193,11 +177,17 @@ class BleConnection(
}
}
/** Returns the maximum write value length for the given write type. */
fun maximumWriteValueLength(writeType: WriteType): Int? = peripheral?.maximumWriteValueLength(writeType)
override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
val nordicWriteType =
when (writeType) {
BleWriteType.WITH_RESPONSE -> WriteType.WITH_RESPONSE
BleWriteType.WITHOUT_RESPONSE -> WriteType.WITHOUT_RESPONSE
}
return _device?.peripheral?.maximumWriteValueLength(nordicWriteType)
}
/** Requests a new connection priority for the current peripheral. */
suspend fun requestConnectionPriority(priority: ConnectionPriority) {
peripheral?.requestConnectionPriority(priority)
_device?.peripheral?.requestConnectionPriority(priority)
}
}

View file

@ -16,20 +16,15 @@
*/
package org.meshtastic.core.ble
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import org.meshtastic.core.model.util.anonymize
import kotlinx.coroutines.CoroutineScope
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import javax.inject.Inject
import javax.inject.Singleton
/** A snapshot in time of the state of the bluetooth subsystem. */
data class BluetoothState(
/** Whether we have adequate permissions to query bluetooth state */
val hasPermissions: Boolean = false,
/** If we have adequate permissions and bluetooth is enabled */
val enabled: Boolean = false,
/** If enabled, a list of the currently bonded devices */
val bondedDevices: List<Peripheral> = emptyList(),
) {
override fun toString(): String =
"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map {
it.anonymize
}})"
/** An Android implementation of [BleConnectionFactory]. */
@Singleton
class AndroidBleConnectionFactory @Inject constructor(private val centralManager: CentralManager) :
BleConnectionFactory {
override fun create(scope: CoroutineScope, tag: String): BleConnection =
AndroidBleConnection(centralManager, scope, tag)
}

View file

@ -0,0 +1,63 @@
/*
* 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.ble
import android.annotation.SuppressLint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import no.nordicsemi.kotlin.ble.core.ConnectionState
/** An Android implementation of [BleDevice] that wraps a Nordic [Peripheral]. */
class AndroidBleDevice(val peripheral: Peripheral) : BleDevice {
override val name: String?
get() = peripheral.name
override val address: String
get() = peripheral.address
private val _state = MutableStateFlow<BleConnectionState>(BleConnectionState.Disconnected)
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
@Suppress("MissingPermission")
override val isBonded: Boolean
get() = peripheral.bondState.value == BondState.BONDED
override val isConnected: Boolean
get() = peripheral.isConnected
@SuppressLint("MissingPermission")
override suspend fun readRssi(): Int = peripheral.readRssi()
@SuppressLint("MissingPermission")
override suspend fun bond() {
peripheral.createBond()
}
/** Updates the connection state based on Nordic's [ConnectionState]. */
fun updateState(nordicState: ConnectionState) {
_state.value =
when (nordicState) {
is ConnectionState.Connecting -> BleConnectionState.Connecting
is ConnectionState.Connected -> BleConnectionState.Connected
is ConnectionState.Disconnecting -> BleConnectionState.Disconnecting
is ConnectionState.Disconnected -> BleConnectionState.Disconnected
}
}
}

View file

@ -19,33 +19,17 @@ package org.meshtastic.core.ble
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConjunctionFilterScope
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
import javax.inject.Inject
import kotlin.time.Duration
/**
* A wrapper around [CentralManager]'s scanning capabilities to provide a consistent and easy-to-use API for BLE
* scanning across the application.
* An Android implementation of [BleScanner] using Nordic's [CentralManager].
*
* @param centralManager The Nordic [CentralManager] to use for scanning.
*/
class BleScanner @Inject constructor(private val centralManager: CentralManager) {
class AndroidBleScanner @Inject constructor(private val centralManager: CentralManager) : BleScanner {
/**
* Scans for BLE devices.
*
* @param timeout The duration of the scan.
* @param filterBlock Optional filter configuration block.
* @return A [Flow] of discovered [Peripheral]s.
*/
fun scan(timeout: Duration, filterBlock: (ConjunctionFilterScope.() -> Unit)? = null): Flow<Peripheral> =
if (filterBlock != null) {
centralManager.scan(timeout, filterBlock)
} else {
centralManager.scan(timeout)
}
.distinctByPeripheral()
.map { it.peripheral }
override fun scan(timeout: Duration): Flow<BleDevice> =
centralManager.scan(timeout).distinctByPeripheral().map { AndroidBleDevice(it.peripheral) }
}

View file

@ -0,0 +1,22 @@
/*
* 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.ble
import no.nordicsemi.kotlin.ble.client.RemoteService
/** An Android implementation of [BleService] that wraps a Nordic [RemoteService]. */
class AndroidBleService(val service: RemoteService) : BleService

View file

@ -36,26 +36,18 @@ import org.meshtastic.core.di.ProcessLifecycle
import javax.inject.Inject
import javax.inject.Singleton
/** Repository responsible for maintaining and updating the state of Bluetooth availability. */
/** Android implementation of [BluetoothRepository]. */
@Singleton
class BluetoothRepository
class AndroidBluetoothRepository
@Inject
constructor(
private val dispatchers: CoroutineDispatchers,
@ProcessLifecycle private val processLifecycle: Lifecycle,
private val centralManager: CentralManager,
private val androidEnvironment: AndroidEnvironment,
) {
private val _state =
MutableStateFlow(
BluetoothState(
// Assume we have permission until we get our initial state update to prevent premature
// notifications to the user.
hasPermissions = true,
),
)
val state: StateFlow<BluetoothState>
get() = _state.asStateFlow()
) : BluetoothRepository {
private val _state = MutableStateFlow(BluetoothState(hasPermissions = true))
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
@ -63,25 +55,16 @@ constructor(
}
}
fun refreshState() {
override fun refreshState() {
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
}
/** @return true for a valid Bluetooth address, false otherwise */
fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
override fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
/**
* Initiates bonding with the given peripheral. This is a suspending function that completes when the bonding
* process is finished. After successful bonding, the repository's state is refreshed to include the new bonded
* device.
*
* @param peripheral The peripheral to bond with.
* @throws SecurityException if required Bluetooth permissions are not granted.
* @throws Exception if the bonding process fails.
*/
@SuppressLint("MissingPermission")
suspend fun bond(peripheral: Peripheral) {
peripheral.createBond()
override suspend fun bond(device: BleDevice) {
val androidDevice = device as AndroidBleDevice
androidDevice.peripheral.createBond()
updateBluetoothState()
}
@ -100,16 +83,15 @@ constructor(
}
@SuppressLint("MissingPermission")
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<Peripheral> =
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<BleDevice> =
if (enabled && hasPerms) {
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral)
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral).map { AndroidBleDevice(it) }
} else {
emptyList()
}
/** @return true if the given address is currently bonded to the system. */
@SuppressLint("MissingPermission")
fun isBonded(address: String): Boolean {
override fun isBonded(address: String): Boolean {
val enabled = androidEnvironment.isBluetoothEnabled
val hasPerms = hasRequiredPermissions()
return if (enabled && hasPerms) {
@ -126,7 +108,6 @@ constructor(
androidEnvironment.isLocationPermissionGranted
}
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
val hasRequiredService =

View file

@ -0,0 +1,69 @@
/*
* 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.ble
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharedFlow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
/** Represents the type of write operation. */
enum class BleWriteType {
WITH_RESPONSE,
WITHOUT_RESPONSE,
}
/** Encapsulates a BLE connection to a [BleDevice]. */
interface BleConnection {
/** The currently connected [BleDevice], or null if not connected. */
val device: BleDevice?
/** A flow of the current device. */
val deviceFlow: SharedFlow<BleDevice?>
/** A flow of [BleConnectionState] changes. */
val connectionState: SharedFlow<BleConnectionState>
/** Connects to the given [BleDevice]. */
suspend fun connect(device: BleDevice)
/** Connects to the given [BleDevice] and waits for a terminal state. */
suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit = {},
): BleConnectionState
/** Disconnects from the current device. */
suspend fun disconnect()
/** Executes a block within a discovered profile. */
suspend fun <T> profile(
serviceUuid: Uuid,
timeout: Duration = 30.seconds,
setup: suspend CoroutineScope.(BleService) -> T,
): T
/** Returns the maximum write value length for the given write type. */
fun maximumWriteValueLength(writeType: BleWriteType): Int?
}
/** Represents a BLE service for commonMain. */
interface BleService {
// This will be expanded as needed, but for now we just need a common type to pass around.
}

View file

@ -0,0 +1,31 @@
/*
* 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.ble
import kotlinx.coroutines.CoroutineScope
/** A factory for creating [BleConnection] instances. */
interface BleConnectionFactory {
/**
* Creates a new [BleConnection] instance.
*
* @param scope The [CoroutineScope] in which to monitor connection state.
* @param tag A tag for logging.
* @return A new [BleConnection] instance.
*/
fun create(scope: CoroutineScope, tag: String): BleConnection
}

View file

@ -0,0 +1,32 @@
/*
* 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.ble
/** Represents the state of a BLE connection. */
sealed class BleConnectionState {
/** The peripheral is disconnected. */
object Disconnected : BleConnectionState()
/** The peripheral is connecting. */
object Connecting : BleConnectionState()
/** The peripheral is connected. */
object Connected : BleConnectionState()
/** The peripheral is disconnecting. */
object Disconnecting : BleConnectionState()
}

View file

@ -0,0 +1,43 @@
/*
* 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.ble
import kotlinx.coroutines.flow.StateFlow
/** Represents a BLE device. */
interface BleDevice {
/** The device's name. */
val name: String?
/** The device's address. */
val address: String
/** The current connection state of the device. */
val state: StateFlow<BleConnectionState>
/** Whether the device is bonded. */
val isBonded: Boolean
/** Whether the device is currently connected. */
val isConnected: Boolean
/** Reads the current RSSI value. */
suspend fun readRssi(): Int
/** Bond the device. */
suspend fun bond()
}

View file

@ -0,0 +1,31 @@
/*
* 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.ble
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
/** A scanner for BLE devices. */
interface BleScanner {
/**
* Scans for BLE devices.
*
* @param timeout The duration of the scan.
* @return A [Flow] of discovered [BleDevice]s.
*/
fun scan(timeout: Duration): Flow<BleDevice>
}

View file

@ -0,0 +1,49 @@
/*
* 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.ble
import kotlinx.coroutines.flow.StateFlow
/** Repository responsible for Bluetooth availability and bonding. */
interface BluetoothRepository {
/** The current state of Bluetooth on the device. */
val state: StateFlow<BluetoothState>
/** Refreshes the Bluetooth state. */
fun refreshState()
/** Returns true if the given address is valid. */
fun isValid(bleAddress: String): Boolean
/** Returns true if the given address is bonded. */
fun isBonded(address: String): Boolean
/** Initiates bonding with the given device. */
suspend fun bond(device: BleDevice)
}
/** Represents the state of Bluetooth on the device. */
data class BluetoothState(
/** True if the application has the required Bluetooth permissions. */
val hasPermissions: Boolean = false,
/** True if Bluetooth is enabled on the device. */
val enabled: Boolean = false,
/** A list of bonded devices. */
val bondedDevices: List<BleDevice> = emptyList(),
)

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/>.
*/
package org.meshtastic.core.ble
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.native
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.NativeAndroidEnvironment
import org.meshtastic.core.di.CoroutineDispatchers
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object BleModule {
@Provides
@Singleton
fun provideAndroidEnvironment(@ApplicationContext context: Context): AndroidEnvironment =
NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)
@Provides
@Singleton
fun provideCentralManager(environment: AndroidEnvironment, coroutineScope: CoroutineScope): CentralManager =
CentralManager.native(environment as NativeAndroidEnvironment, coroutineScope)
@Provides
@Singleton
fun provideBleSingletonCoroutineScope(dispatchers: CoroutineDispatchers): CoroutineScope =
CoroutineScope(SupervisorJob() + dispatchers.default)
}

View file

@ -152,7 +152,6 @@ constructor(
if (queueJob?.isActive == true) return
queueJob =
scope.handledLaunch {
Logger.d { "packet queueJob started" }
try {
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break

View file

@ -14,34 +14,44 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.devtools.ksp)
}
configure<LibraryExtension> {
buildFeatures { aidl = true }
namespace = "org.meshtastic.core.service"
kotlin {
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.service"
androidResources.enable = false
}
testOptions { unitTests.isReturnDefaultValues = true }
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(libs.javax.inject)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kermit)
}
androidMain.dependencies {
api(projects.core.api)
implementation(libs.hilt.android)
}
commonTest.dependencies {
implementation(libs.junit)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.mockk)
implementation(libs.turbine)
}
}
}
dependencies {
api(projects.core.api)
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(libs.javax.inject)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kermit)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.turbine)
}
dependencies { add("kspAndroid", libs.hilt.compiler) }

Some files were not shown because too many files have changed in this diff Show more