refactor(ble): Centralize BLE logic into a core module (#4550)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-20 06:41:52 -06:00 committed by GitHub
parent 7a68802bc2
commit 6bfa5b5f70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 3471 additions and 2405 deletions

View file

@ -19,6 +19,7 @@ This file serves as a comprehensive guide for AI agents and developers working o
| :--- | :--- |
| `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. |
@ -58,13 +59,20 @@ This file serves as a comprehensive guide for AI agents and developers working o
- 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`.
### D. Dependency Management
### 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.
### 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.
### E. Build Variants (Flavors)
### 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`
@ -83,7 +91,10 @@ This file serves as a comprehensive guide for AI agents and developers working o
### C. Testing
- **Unit Tests:** JUnit 4/5 in `src/test/java`. Run with `./gradlew test`.
- **UI Tests:** Espresso/Compose in `src/androidTest/java`. Run with `./gradlew connectedAndroidTest`.
- **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

View file

@ -19,15 +19,16 @@ Thank you for your interest in contributing to Meshtastic-Android! We welcome co
- Write clear, descriptive variable and function names.
- Add comments where necessary, especially for complex logic.
- Keep methods and classes focused and concise.
- Use localised strings; edit the English [`strings.xml`](app/src/main/res/values/strings.xml) file. CrowdIn will manage translations to other languages.
- For example,
- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:strings`.
- Do **not** use the legacy `app/src/main/res/values/strings.xml`.
- **Definition:** Add strings to `core/strings/src/commonMain/composeResources/values/strings.xml`.
- **Usage:**
```kotlin
// instead of hardcoding a string in your code:
Text("Settings")
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.your_string_key
// use the localised string resource:
Text(stringResource(R.string.settings))
Text(text = stringResource(Res.string.your_string_key))
```
### Linting
@ -43,18 +44,20 @@ Consistent linting helps keep the codebase clean and maintainable.
### Testing
Meshtastic-Android uses both unit tests and instrumented UI tests to ensure code quality and reliability.
Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI tests to ensure code quality and reliability.
- **Unit tests** are located in `app/src/test/java/` and should be written for all new logic where possible.
- **Instrumented tests** (including UI tests using Jetpack Compose) are located in `app/src/androidTest/java/`. For Compose UI, use the [Jetpack Compose Testing APIs](https://developer.android.com/jetpack/compose/testing).
- **Unit tests** are located in the `src/test/` directory of each module.
- **Compose UI Tests (JVM)** are preferred for component testing and are also located in `src/test/` using **Robolectric**.
- Note: If using Java 17, pin your Robolectric tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility issues.
- **Instrumented tests** (including full E2E UI tests) are located in `src/androidTest/`. For Compose UI, use the [Jetpack Compose Testing APIs](https://developer.android.com/jetpack/compose/testing).
#### Guidelines for Testing
- Add or update tests for any new features or bug fixes.
- Ensure all tests pass by running:
- `./gradlew test` for unit tests
- `./gradlew test` for unit and Robolectric tests
- `./gradlew connectedAndroidTest` for instrumented tests
- For UI components, write Compose UI tests to verify user interactions and visual elements. See existing tests in `DebugFiltersTest.kt` for examples.
- For UI components, write Robolectric Compose tests where possible for faster execution.
- If your change is difficult to test, explain why in your pull request.
Comprehensive testing helps prevent regressions and ensures a stable experience for all users.
@ -70,7 +73,7 @@ Comprehensive testing helps prevent regressions and ensures a stable experience
- reserved (release, automation)
- Ensure your branch is up to date with the latest `main` branch before submitting a PR.
- Provide a meaningful title and description for your PR.
- Inlude information on how to test and/or replicate if it is not obvious.
- Include information on how to test and/or replicate if it is not obvious.
- Include screenshots or logs if your change affects the UI or user experience.
- Be responsive to feedback and make requested changes promptly.
- Squash commits if requested by a maintainer.

View file

@ -56,6 +56,19 @@ You can generate the documentation locally to preview your changes.
2. **View the output:**
The generated HTML files will be located in the `app/build/dokka/html` directory. You can open the `index.html` file in your browser to view the documentation.
## Architecture
### Modern Android Development (MAD)
The app follows modern Android development practices:
- **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).
### 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.
## Translations
You can help translate the app into your native language using [Crowdin](https://crowdin.meshtastic.org/android).

View file

@ -1,11 +1,53 @@
# `:app`
## Overview
The `:app` module is the entry point for the Meshtastic Android application. It orchestrates the various feature modules, manages global state, and provides the main UI shell.
## Key Components
### 1. `MainActivity` & `Main.kt`
The single Activity of the application. It hosts the `NavHost` and manages the root UI structure (Navigation Bar, Rail, etc.).
### 2. `MeshService`
The core background service that manages long-running communication with the mesh radio. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background.
### 3. Hilt Application
`MeshUtilApplication` is the Hilt entry point, providing the global dependency injection container.
## Architecture
The module primarily serves as a "glue" layer, connecting:
- `core:*` modules for shared logic.
- `feature:*` modules for specific user-facing screens.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:app[app]:::null
:app[app]:::android-application
:app -.-> :core:analytics
:app -.-> :core:ble
:app -.-> :core:common
:app -.-> :core:data
:app -.-> :core:database
:app -.-> :core:datastore
:app -.-> :core:di
:app -.-> :core:model
:app -.-> :core:navigation
:app -.-> :core:network
:app -.-> :core:nfc
:app -.-> :core:prefs
:app -.-> :core:proto
:app -.-> :core:service
:app -.-> :core:strings
:app -.-> :core:ui
:app -.-> :core:barcode
:app -.-> :feature:intro
:app -.-> :feature:messaging
:app -.-> :feature:map
:app -.-> :feature:node
:app -.-> :feature:settings
:app -.-> :feature:firmware
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -141,7 +141,10 @@ configure<ApplicationExtension> {
// Configure existing product flavors (defined by convention plugin)
// with their dynamic version names.
productFlavors {
named("google") { versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) google" }
named("google") {
versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) google"
manifestPlaceholders["MAPS_API_KEY"] = "dummy"
}
named("fdroid") { versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) fdroid" }
}
@ -158,6 +161,8 @@ configure<ApplicationExtension> {
}
}
bundle { language { enableSplit = false } }
testOptions { unitTests { isIncludeAndroidResources = true } }
}
secrets {
@ -195,6 +200,7 @@ project.afterEvaluate {
dependencies {
implementation(projects.core.analytics)
implementation(projects.core.ble)
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
@ -225,9 +231,7 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
@ -246,6 +250,11 @@ dependencies {
implementation(libs.kermit)
implementation(libs.nordic.client.android)
implementation(libs.nordic.common.core)
implementation(libs.nordic.common.permissions.ble)
implementation(libs.nordic.common.permissions.notification)
implementation(libs.nordic.common.scanner.ble)
implementation(libs.nordic.common.ui)
debugImplementation(libs.androidx.compose.ui.test.manifest)
@ -259,16 +268,20 @@ dependencies {
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.nordic.client.android.mock)
androidTestImplementation(libs.nordic.core.mock)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.nordic.client.android.mock)
testImplementation(libs.nordic.client.mock)
testImplementation(libs.nordic.client.core.mock)
testImplementation(libs.nordic.core.mock)
testImplementation(libs.nordic.core.android.mock)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)
}
aboutLibraries {

View file

@ -112,7 +112,8 @@
android:hardwareAccelerated="true"
android:theme="@style/SplashTheme"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config">
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true">
<uses-library
android:name="org.apache.http.legacy"

View file

@ -26,17 +26,17 @@ import android.nfc.NdefMessage
import android.nfc.NfcAdapter
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.ReportDrawnWhen
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalView
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -46,7 +46,8 @@ import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.MainScreen
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.meshtastic.core.datastore.UiPreferencesDataSource
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.strings.Res
@ -58,59 +59,64 @@ import org.meshtastic.feature.intro.AppIntroductionScreen
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModels()
// This is aware of the Activity lifecycle and handles binding to the mesh service.
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
*/
@Inject internal lateinit var meshServiceClient: MeshServiceClient
@Inject internal lateinit var uiPreferencesDataSource: UiPreferencesDataSource
@Inject internal lateinit var androidEnvironment: AndroidEnvironment
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge(
// Disable three-button navbar scrim on pre-Q devices
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Disable three-button navbar scrim
window.setNavigationBarContrastEnforced(false)
}
super.onCreate(savedInstanceState)
setContent {
val theme by model.theme.collectAsState()
val theme by model.theme.collectAsStateWithLifecycle()
val dynamic = theme == MODE_DYNAMIC
val dark =
when (theme) {
AppCompatDelegate.MODE_NIGHT_YES -> true
AppCompatDelegate.MODE_NIGHT_NO -> false
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
else -> isSystemInDarkTheme()
}
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect { AppCompatDelegate.setDefaultNightMode(theme) }
}
// Apply modern edge-to-edge drawing with theme-aware system bars
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
)
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
if (appIntroCompleted) {
MainScreen(uIViewModel = model)
} else {
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
// Ensure the navigation bar remains seamless on modern Android versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
@Suppress("SpreadOperator")
CompositionLocalProvider(*(LocalEnvironmentOwner provides androidEnvironment)) {
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn"
// once we've decided whether to show the intro or the main screen.
ReportDrawnWhen { true }
if (appIntroCompleted) {
MainScreen(uIViewModel = model)
} else {
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() })
}
}
}
}
if (savedInstanceState == null) {
handleIntent(intent)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Listen for new intents (e.g. deep links, NFC) without overriding onNewIntent
addOnNewIntentListener { intent -> handleIntent(intent) }
handleIntent(intent)
}
@ -125,7 +131,12 @@ class MainActivity : AppCompatActivity() {
}
NfcAdapter.ACTION_NDEF_DISCOVERED -> {
val rawMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
val rawMessages =
IntentCompat.getParcelableArrayExtra(
intent,
NfcAdapter.EXTRA_NDEF_MESSAGES,
NdefMessage::class.java,
)
if (rawMessages != null) {
for (rawMsg in rawMessages) {
val msg = rawMsg as NdefMessage

View file

@ -17,21 +17,21 @@
package com.geeksville.mesh
import android.content.Context
import androidx.appcompat.app.AppCompatActivity.BIND_ABOVE_CLIENT
import androidx.appcompat.app.AppCompatActivity.BIND_AUTO_CREATE
import android.content.Context.BIND_ABOVE_CLIENT
import android.content.Context.BIND_AUTO_CREATE
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import com.geeksville.mesh.android.BindFailedException
import com.geeksville.mesh.android.ServiceClient
import com.geeksville.mesh.concurrent.SequentialJob
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.startService
import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.SequentialJob
import org.meshtastic.core.service.BindFailedException
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceClient
import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
@ -84,6 +84,12 @@ constructor(
}
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
Logger.d { "Lifecycle: ON_STOP" }
close()
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
Logger.d { "Lifecycle: ON_DESTROY" }
@ -103,6 +109,6 @@ constructor(
Logger.e { "Failed to start service from activity - but ignoring because bind will work: ${ex.message}" }
}
connect(context, MeshService.createIntent(context), BIND_AUTO_CREATE + BIND_ABOVE_CLIENT)
connect(context, MeshService.createIntent(context), BIND_AUTO_CREATE or BIND_ABOVE_CLIENT)
}
}

View file

@ -1,53 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.android
import android.content.Context
import java.io.File
import java.io.FileOutputStream
import java.io.PrintWriter
/**
* Create a debug log on the SD card (if needed and allowed and app is configured for debugging (FIXME)
*
* write strings to that file
*/
class DebugLogFile(context: Context, name: String) {
val stream = FileOutputStream(File(context.getExternalFilesDir(null), name), true)
val file = PrintWriter(stream)
fun close() {
file.close()
}
fun log(s: String) {
file.println(s) // FIXME, optionally include timestamps
file.flush() // for debugging
}
}
/**
* Create a debug log on the SD card (if needed and allowed and app is configured for debugging (FIXME)
*
* write strings to that file
*/
class BinaryLogFile(context: Context, name: String) :
FileOutputStream(File(context.getExternalFilesDir(null), name), true) {
}

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,7 +14,6 @@
* 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 com.geeksville.mesh.model
import android.hardware.usb.UsbManager
@ -23,6 +22,8 @@ import com.geeksville.mesh.repository.radio.RadioInterfaceService
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.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.anonymize
/**
@ -33,34 +34,65 @@ import org.meshtastic.core.model.util.anonymize
* @param name The display name of the device.
* @param fullAddress The unique address of the device, prefixed with a type identifier.
* @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB).
* @param node The [Node] associated with this device, if found in the database.
*/
sealed class DeviceListEntry(open val name: String, open val fullAddress: String, open val bonded: Boolean) {
sealed class DeviceListEntry(
open val name: String,
open val fullAddress: String,
open val bonded: Boolean,
open val node: Node? = null,
) {
val address: String
get() = fullAddress.substring(1)
abstract fun copy(node: Node?): DeviceListEntry
override fun toString(): String =
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)"
"DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded, hasNode=${node != null})"
@Suppress("MissingPermission")
data class Ble(val peripheral: Peripheral) :
data class Ble(val peripheral: Peripheral, override val node: Node? = null) :
DeviceListEntry(
name = peripheral.name ?: "unnamed-${peripheral.address}",
fullAddress = "x${peripheral.address}",
bonded = peripheral.bondState.value == BondState.BONDED,
)
node = node,
) {
override fun copy(node: Node?): Ble = copy(peripheral = peripheral, node = node)
}
data class Usb(
private val radioInterfaceService: RadioInterfaceService,
private val usbManager: UsbManager,
val driver: UsbSerialDriver,
override val node: Node? = null,
) : DeviceListEntry(
name = driver.device.deviceName,
fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName),
bonded = usbManager.hasPermission(driver.device),
)
node = node,
) {
override fun copy(node: Node?): Usb =
copy(radioInterfaceService = radioInterfaceService, usbManager = usbManager, driver = driver, node = node)
}
data class Tcp(override val name: String, override val fullAddress: String) :
DeviceListEntry(name, fullAddress, true)
data class Tcp(override val name: String, override val fullAddress: String, override val node: Node? = null) :
DeviceListEntry(name, fullAddress, true, node) {
override fun copy(node: Node?): Tcp = copy(name = name, fullAddress = fullAddress, node = node)
}
data class Mock(override val name: String) : DeviceListEntry(name, "m", true)
data class Mock(override val name: String, override val node: Node? = null) :
DeviceListEntry(name, "m", true, node) {
override fun copy(node: Node?): Mock = copy(name = name, node = node)
}
}
/** Matches names like Meshtastic_1234. */
private val bleNameRegex = Regex(BLE_NAME_PATTERN)
/**
* Returns the short name of the device if it's a Meshtastic device, otherwise null.
*
* @return The short name (e.g., 1234) or null.
*/
fun Peripheral.getMeshtasticShortName(): String? = name?.let { bleNameRegex.find(it)?.groupValues?.get(1) }

View file

@ -16,12 +16,9 @@
*/
package com.geeksville.mesh.model
import android.app.Application
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
@ -72,42 +69,11 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
// Given a human name, strip out the first letter of the first three words and return that as the
// initials for
// that user, ignoring emojis. If the original name is only one word, strip vowels from the original
// name and if the result is 3 or more characters, use the first three characters. If not, just take
// the first 3 characters of the original name.
fun getInitials(fullName: String): String {
val maxInitialLength = 4
val minWordCountForInitials = 2
val name = fullName.trim().withoutEmojis()
val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() }
val initials =
when (words.size) {
in 0 until minWordCountForInitials -> {
val nameWithoutVowels =
if (name.isNotEmpty()) {
name.first() + name.drop(1).filterNot { c -> c.lowercase() in "aeiou" }
} else {
""
}
if (nameWithoutVowels.length >= maxInitialLength) nameWithoutVowels else name
}
else -> words.map { it.first() }.joinToString("")
}
return initials.take(maxInitialLength)
}
private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
@Suppress("LongParameterList", "LargeClass", "UnusedPrivateProperty")
@HiltViewModel
@Suppress("LongParameterList", "TooManyFunctions")
class UIViewModel
@Inject
constructor(
private val app: Application,
private val nodeDB: NodeRepository,
private val serviceRepository: ServiceRepository,
radioInterfaceService: RadioInterfaceService,
@ -293,8 +259,7 @@ constructor(
serviceRepository.clearTracerouteResponse()
}
val neighborInfoResponse: LiveData<String?>
get() = serviceRepository.neighborInfoResponse.asLiveData()
val neighborInfoResponse: StateFlow<String?> = serviceRepository.neighborInfoResponse
fun clearNeighborInfoResponse() {
serviceRepository.clearNeighborInfoResponse()

View file

@ -1,55 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.repository.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.geeksville.mesh.util.exceptionReporter
import javax.inject.Inject
/**
* A helper class to call onChanged when bluetooth is enabled or disabled
*/
class BluetoothBroadcastReceiver @Inject constructor(
private val bluetoothRepository: BluetoothRepository
) : BroadcastReceiver() {
internal val intentFilter get() = IntentFilter().apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
}
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
when (intent.bluetoothAdapterState) {
// Simulate a disconnection if the user disables bluetooth entirely
BluetoothAdapter.STATE_OFF -> bluetoothRepository.refreshState()
BluetoothAdapter.STATE_ON -> bluetoothRepository.refreshState()
}
}
if (intent.action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
bluetoothRepository.refreshState()
}
}
private val Intent.bluetoothAdapterState: Int
get() = getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)
}

View file

@ -1,49 +0,0 @@
/*
* Copyright (c) 2025 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 com.geeksville.mesh.repository.bluetooth
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.annotation.RequiresPermission
import com.geeksville.mesh.util.registerReceiverCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@RequiresPermission("android.permission.BLUETOOTH_CONNECT")
internal fun BluetoothDevice.createBond(context: Context): Flow<Int> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
trySend(state)
// we stay registered until bonding completes (either with BONDED or NONE)
if (state != BluetoothDevice.BOND_BONDING) {
close()
}
}
}
val filter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
context.registerReceiverCompat(receiver, filter)
createBond()
awaitClose { context.unregisterReceiver(receiver) }
}

View file

@ -17,7 +17,6 @@
package com.geeksville.mesh.repository.network
import co.touchlab.kermit.Logger
import com.geeksville.mesh.util.ignoreException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@ -31,6 +30,7 @@ import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.MqttMessage
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.util.subscribeList

View file

@ -17,16 +17,16 @@
package com.geeksville.mesh.repository.radio
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.model.getInitials
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
import okio.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.getInitials
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data

View file

@ -18,15 +18,10 @@ package com.geeksville.mesh.repository.radio
import android.annotation.SuppressLint
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMNUM_CHARACTER
import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMRADIO_CHARACTER
import com.geeksville.mesh.repository.radio.BleConstants.BTM_LOGRADIO_CHARACTER
import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID
import com.geeksville.mesh.repository.radio.BleConstants.BTM_TORADIO_CHARACTER
import com.geeksville.mesh.service.RadioNotConnectedException
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@ -36,26 +31,38 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
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.client.exception.InvalidAttributeException
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import org.meshtastic.core.model.util.nowMillis
import java.util.UUID
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.toKotlinUuid
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
import org.meshtastic.core.ble.retryBleOperation
import org.meshtastic.core.common.util.nowMillis
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 1000L
private const val CONNECTION_TIMEOUT_MS = 15_000L
private val SCAN_TIMEOUT = 5.seconds
/**
* A [IRadioInterface] implementation for BLE devices using Nordic Kotlin BLE Library.
@ -82,7 +89,7 @@ constructor(
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
serviceScope.launch {
try {
peripheral?.disconnect()
bleConnection.disconnect()
} catch (e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
}
@ -90,11 +97,12 @@ constructor(
service.onDisconnect(BleError.from(throwable))
}
private val connectionScope = CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
private val drainMutex = Mutex()
private val writeMutex = Mutex()
private val connectionScope: CoroutineScope =
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
private val bleConnection: BleConnection = BleConnection(centralManager, connectionScope, address)
private val drainMutex: Mutex = Mutex()
private val writeMutex: Mutex = Mutex()
private var peripheral: Peripheral? = null
private var connectionStartTime: Long = 0
private var packetsReceived: Int = 0
private var packetsSent: Int = 0
@ -150,10 +158,8 @@ constructor(
private suspend fun drainPacketQueueAndDispatch() {
drainMutex.withLock {
var drainedCount = 0
fromRadioPacketFlow()
.onEach { packet ->
drainedCount++
Logger.d { "[$address] Read packet from queue (${packet.size} bytes)" }
dispatchPacket(packet)
}
@ -164,217 +170,181 @@ constructor(
// --- Connection & Discovery Logic ---
private fun findPeripheral(): Peripheral =
centralManager.getBondedPeripherals().firstOrNull { it.address == address }
?: throw RadioNotConnectedException("Device not found at address $address")
/** Robustly finds the peripheral. First checks bonded devices, then performs a short scan if not found. */
private suspend fun findPeripheral(): Peripheral {
centralManager
.getBondedPeripherals()
.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
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY_MS)
}
}
throw RadioNotConnectedException("Device not found at address $address")
}
private fun connect() {
connectionScope.launch {
try {
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started at $connectionStartTime" }
Logger.i { "[$address] BLE connection attempt started" }
peripheral = retryCall { findAndConnectPeripheral() }
peripheral?.let {
val connectionTime = nowMillis - connectionStartTime
Logger.i { "[$address] BLE peripheral connected in ${connectionTime}ms" }
onConnected()
observePeripheralChanges()
discoverServicesAndSetupCharacteristics(it)
bleConnection.connectionState
.onEach { state ->
if (state is ConnectionState.Disconnected) {
onDisconnected(state)
}
}
.launchIn(connectionScope)
val p = retryBleOperation(tag = address) { findPeripheral() }
val state = bleConnection.connectAndAwait(p, CONNECTION_TIMEOUT_MS)
if (state !is ConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
onConnected()
discoverServicesAndSetupCharacteristics()
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
// BLE connection errors are common and often transient
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
service.onDisconnect(BleError.from(e))
}
}
}
private suspend fun findAndConnectPeripheral(): Peripheral {
val p = findPeripheral()
centralManager.connect(
peripheral = p,
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
)
p.requestConnectionPriority(ConnectionPriority.HIGH)
return p
}
private suspend fun onConnected() {
try {
peripheral?.let { p ->
val rssi = retryCall { p.readRssi() }
Logger.d { "[$address] Connection established. RSSI: $rssi dBm" }
val phyInUse = retryCall { p.readPhy() }
Logger.d { "[$address] PHY in use: $phyInUse" }
bleConnection.peripheral?.let { p ->
val rssi = retryBleOperation(tag = address) { p.readRssi() }
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Failed to read initial connection properties" }
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
}
}
private fun observePeripheralChanges() {
peripheral?.let { p ->
p.phy.onEach { phy -> Logger.i { "[$address] BLE PHY changed to $phy" } }.launchIn(connectionScope)
private fun onDisconnected(state: ConnectionState.Disconnected) {
clearCharacteristics()
p.connectionParameters
.onEach { params -> Logger.i { "[$address] BLE connection parameters changed to $params" } }
.launchIn(connectionScope)
p.state
.onEach { state ->
Logger.i { "[$address] BLE connection state changed to $state" }
if (state is ConnectionState.Disconnected) {
clearCharacteristics()
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.w {
"[$address] BLE disconnected - Reason: ${state.reason}, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
service.onDisconnect(BleError.Disconnected(reason = state.reason))
}
}
.launchIn(connectionScope)
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.w {
"[$address] BLE disconnected - Reason: ${state.reason}, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
centralManager.state
.onEach { state -> Logger.i { "[$address] CentralManager state changed to $state" } }
.launchIn(connectionScope)
service.onDisconnect(BleError.Disconnected(reason = state.reason))
}
@Suppress("TooGenericExceptionCaught")
@OptIn(ExperimentalUuidApi::class)
private fun discoverServicesAndSetupCharacteristics(peripheral: Peripheral) {
connectionScope.launch {
peripheral
.services(listOf(BTM_SERVICE_UUID.toKotlinUuid()))
.onEach { services ->
val meshtasticService = services?.find { it.uuid == BTM_SERVICE_UUID.toKotlinUuid() }
private suspend fun discoverServicesAndSetupCharacteristics() {
try {
val chars =
bleConnection.discoverCharacteristics(
SERVICE_UUID,
listOf(
TORADIO_CHARACTERISTIC,
FROMNUM_CHARACTERISTIC,
FROMRADIO_CHARACTERISTIC,
LOGRADIO_CHARACTERISTIC,
),
)
if (meshtasticService != null) {
toRadioCharacteristic =
meshtasticService.characteristics.find { it.uuid == BTM_TORADIO_CHARACTER.toKotlinUuid() }
fromNumCharacteristic =
meshtasticService.characteristics.find { it.uuid == BTM_FROMNUM_CHARACTER.toKotlinUuid() }
fromRadioCharacteristic =
meshtasticService.characteristics.find { it.uuid == BTM_FROMRADIO_CHARACTER.toKotlinUuid() }
logRadioCharacteristic =
meshtasticService.characteristics.find { it.uuid == BTM_LOGRADIO_CHARACTER.toKotlinUuid() }
if (chars != null) {
toRadioCharacteristic = chars[TORADIO_CHARACTERISTIC]
fromNumCharacteristic = chars[FROMNUM_CHARACTERISTIC]
fromRadioCharacteristic = chars[FROMRADIO_CHARACTERISTIC]
logRadioCharacteristic = chars[LOGRADIO_CHARACTERISTIC]
if (
listOf(toRadioCharacteristic, fromNumCharacteristic, fromRadioCharacteristic).all {
it != null
}
) {
Logger.d {
"[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}"
}
Logger.d {
"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}"
}
Logger.d {
"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}"
}
Logger.d {
"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}"
}
setupNotifications()
service.onConnect()
} else {
Logger.w { "[$address] Discovery failed: missing required characteristics" }
service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found"))
}
} else {
Logger.w { "[$address] Discovery failed: Meshtastic service not found" }
service.onDisconnect(BleError.DiscoveryFailed("Meshtastic service not found"))
}
}
.catch { e ->
Logger.w(e) { "[$address] Service discovery failed" }
try {
peripheral.disconnect()
} catch (e2: Exception) {
Logger.w(e2) { "[$address] Failed to disconnect in discovery catch" }
}
service.onDisconnect(BleError.from(e))
}
.launchIn(connectionScope)
Logger.d { "[$address] Characteristics discovered successfully" }
setupNotifications()
service.onConnect()
} else {
Logger.w { "[$address] Discovery failed: missing required characteristics" }
service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found"))
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Service discovery failed" }
bleConnection.disconnect()
service.onDisconnect(BleError.from(e))
}
}
// --- Notification Setup ---
@OptIn(ExperimentalUuidApi::class)
private suspend fun setupNotifications() {
retryCall { fromNumCharacteristic?.subscribe() }
?.onStart { Logger.d { "[$address] Subscribing to fromNumCharacteristic" } }
val fromNumReady = CompletableDeferred<Unit>()
val logRadioReady = CompletableDeferred<Unit>()
fromNumCharacteristic
?.subscribe {
Logger.d { "[$address] FromNum subscription active" }
fromNumReady.complete(Unit)
}
?.onEach { notifyBytes ->
Logger.d { "[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue" }
connectionScope.launch { drainPacketQueueAndDispatch() }
}
?.catch { e ->
Logger.w(e) { "[$address] Error subscribing to fromNumCharacteristic" }
if (!fromNumReady.isCompleted) fromNumReady.completeExceptionally(e)
Logger.w(e) { "[$address] Error in fromNumCharacteristic subscription" }
service.onDisconnect(BleError.from(e))
}
?.launchIn(scope = connectionScope)
?.launchIn(connectionScope) ?: fromNumReady.complete(Unit)
retryCall { logRadioCharacteristic?.subscribe() }
?.onStart { Logger.d { "[$address] Subscribing to logRadioCharacteristic" } }
logRadioCharacteristic
?.subscribe {
Logger.d { "[$address] LogRadio subscription active" }
logRadioReady.complete(Unit)
}
?.onEach { notifyBytes ->
Logger.d { "[$address] LogRadio Notification (${notifyBytes.size} bytes), dispatching packet" }
dispatchPacket(notifyBytes)
}
?.catch { e ->
Logger.w(e) { "[$address] Error subscribing to logRadioCharacteristic" }
if (!logRadioReady.isCompleted) logRadioReady.completeExceptionally(e)
Logger.w(e) { "[$address] Error in logRadioCharacteristic subscription" }
service.onDisconnect(BleError.from(e))
}
?.launchIn(scope = connectionScope)
}
?.launchIn(connectionScope) ?: logRadioReady.complete(Unit)
private suspend fun <T> retryCall(block: suspend () -> T): T {
var currentAttempt = 0
while (true) {
try {
return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
currentAttempt++
if (currentAttempt >= RETRY_COUNT) {
Logger.w(e) { "[$address] BLE operation failed after $RETRY_COUNT attempts, giving up" }
throw e
}
Logger.w(e) {
"[$address] BLE operation failed (attempt $currentAttempt/$RETRY_COUNT), " +
"retrying in ${RETRY_DELAY_MS}ms..."
}
delay(RETRY_DELAY_MS)
try {
withTimeout(CONNECTION_TIMEOUT_MS) {
fromNumReady.await()
logRadioReady.await()
}
Logger.d { "[$address] All notifications successfully subscribed" }
} catch (e: Exception) {
Logger.e(e) { "[$address] Timeout or error waiting for characteristic subscriptions" }
throw e
}
}
// --- IRadioInterface Implementation ---
/**
* Sends a packet to the radio.
* Sends a packet to the radio with retry support.
*
* @param p The packet to send.
*/
override fun handleSendToRadio(p: ByteArray) {
toRadioCharacteristic?.let { characteristic ->
if (peripheral == null) {
Logger.w { "[$address] BLE peripheral is null, cannot send packet" }
return@let
}
connectionScope.launch {
writeMutex.withLock {
try {
@ -384,14 +354,15 @@ constructor(
} else {
WriteType.WITH_RESPONSE
}
retryCall {
packetsSent++
bytesSent += p.size
Logger.d {
"[$address] Writing packet #$packetsSent to toRadioCharacteristic with $writeType - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
}
characteristic.write(p, writeType = writeType)
retryBleOperation(tag = address) { characteristic.write(p, writeType = writeType) }
packetsSent++
bytesSent += p.size
Logger.d {
"[$address] Successfully wrote packet #$packetsSent " +
"to toRadioCharacteristic with $writeType - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
}
drainPacketQueueAndDispatch()
} catch (e: InvalidAttributeException) {
@ -429,7 +400,7 @@ constructor(
"Packets TX: $packetsSent ($bytesSent bytes)"
}
connectionScope.cancel()
peripheral?.disconnect()
bleConnection.disconnect()
service.onDisconnect(true)
}
}
@ -445,18 +416,4 @@ constructor(
fromRadioCharacteristic = null
logRadioCharacteristic = null
}
companion object {
private const val RETRY_COUNT = 3
private const val RETRY_DELAY_MS = 500L
}
}
object BleConstants {
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
val BTM_SERVICE_UUID: UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
val BTM_TORADIO_CHARACTER: UUID = UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7")
val BTM_FROMNUM_CHARACTER: UUID = UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453")
val BTM_FROMRADIO_CHARACTER: UUID = UUID.fromString("2c55e69e-4993-11ed-b878-0242ac120002")
val BTM_LOGRADIO_CHARACTER: UUID = UUID.fromString("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
}

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,11 +14,10 @@
* 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 com.geeksville.mesh.repository.radio
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.model.util.anonymize
import javax.inject.Inject

View file

@ -22,19 +22,11 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.android.BinaryLogFile
import com.geeksville.mesh.android.BuildUtils
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.util.ignoreException
import com.geeksville.mesh.util.toRemoteExceptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
@ -43,11 +35,19 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.core.simpleSharedFlow
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.util.BinaryLogFile
import org.meshtastic.core.common.util.BuildUtils
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.Heartbeat
@ -82,10 +82,10 @@ constructor(
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _receivedData = MutableSharedFlow<ByteArray>()
private val _receivedData = simpleSharedFlow<ByteArray>()
val receivedData: SharedFlow<ByteArray> = _receivedData
private val _connectionError = MutableSharedFlow<BleError>()
private val _connectionError = simpleSharedFlow<BleError>()
val connectionError: SharedFlow<BleError> = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
@ -371,12 +371,7 @@ constructor(
serviceScope.handledLaunch { handleSendToRadio(a) }
}
private val _meshActivity =
MutableSharedFlow<MeshActivity>(
replay = 0, // No replay needed for event-like emissions
extraBufferCapacity = 1, // Buffer one event to avoid loss on rapid emissions
onBufferOverflow = BufferOverflow.DROP_OLDEST, // Drop oldest if buffer overflows
)
private val _meshActivity = simpleSharedFlow<MeshActivity>()
val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
private fun emitSendActivity() {

View file

@ -22,7 +22,7 @@ import com.geeksville.mesh.repository.usb.SerialConnectionListener
import com.geeksville.mesh.repository.usb.UsbRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.common.util.nowMillis
import java.util.concurrent.atomic.AtomicReference
/** An interface that assumes we are talking to a meshtastic device via USB serial */

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,7 +14,6 @@
* 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 com.geeksville.mesh.repository.radio
import android.hardware.usb.UsbManager
@ -22,24 +21,18 @@ import com.geeksville.mesh.repository.usb.UsbRepository
import com.hoho.android.usbserial.driver.UsbSerialDriver
import javax.inject.Inject
/**
* Serial/USB interface backend implementation.
*/
class SerialInterfaceSpec @Inject constructor(
/** Serial/USB interface backend implementation. */
class SerialInterfaceSpec
@Inject
constructor(
private val factory: SerialInterfaceFactory,
private val usbManager: dagger.Lazy<UsbManager>,
private val usbRepository: UsbRepository,
) : InterfaceSpec<SerialInterface> {
override fun createInterface(rest: String): SerialInterface {
return factory.create(rest)
}
override fun createInterface(rest: String): SerialInterface = factory.create(rest)
override fun addressValid(
rest: String
): Boolean {
usbRepository.serialDevicesWithDrivers.value.filterValues {
usbManager.get().hasPermission(it.device)
}
override fun addressValid(rest: String): Boolean {
usbRepository.serialDevices.value.filterValues { usbManager.get().hasPermission(it.device) }
findSerial(rest)?.let { d ->
return usbManager.get().hasPermission(d.device)
}
@ -47,7 +40,7 @@ class SerialInterfaceSpec @Inject constructor(
}
internal fun findSerial(rest: String): UsbSerialDriver? {
val deviceMap = usbRepository.serialDevicesWithDrivers.value
val deviceMap = usbRepository.serialDevices.value
return if (deviceMap.containsKey(rest)) {
deviceMap[rest]!!
} else {

View file

@ -17,15 +17,15 @@
package com.geeksville.mesh.repository.radio
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.util.Exceptions
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.Exceptions
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import java.io.BufferedInputStream

View file

@ -18,16 +18,15 @@ package com.geeksville.mesh.repository.usb
import android.hardware.usb.UsbManager
import co.touchlab.kermit.Logger
import com.geeksville.mesh.util.ignoreException
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.util.SerialInputOutputManager
import org.meshtastic.core.model.util.await
import org.meshtastic.core.common.util.ignoreException
import java.nio.BufferOverflowException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.time.Duration.Companion.seconds
internal class SerialConnectionImpl(
private val usbManagerLazy: dagger.Lazy<UsbManager?>,
@ -65,7 +64,7 @@ internal class SerialConnectionImpl(
// Allow a short amount of time for the manager to quit (so the port can be cleanly closed)
if (waitForStopped) {
Logger.d { "Waiting for USB manager to stop..." }
ignoreException(silent = true) { closedLatch.await(1.seconds) }
ignoreException(silent = true) { closedLatch.await(1, TimeUnit.SECONDS) }
}
}

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,7 +14,6 @@
* 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 com.geeksville.mesh.repository.usb
import android.content.BroadcastReceiver
@ -24,8 +23,8 @@ import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import co.touchlab.kermit.Logger
import com.geeksville.mesh.util.exceptionReporter
import com.geeksville.mesh.util.getParcelableExtraCompat
import org.meshtastic.core.common.util.exceptionReporter
import org.meshtastic.core.common.util.getParcelableExtraCompat
import javax.inject.Inject
/** A helper class to call onChanged when bluetooth is enabled or disabled or when permissions are changed. */

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,7 +14,6 @@
* 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 com.geeksville.mesh.repository.usb
import android.content.BroadcastReceiver
@ -24,33 +23,32 @@ import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import androidx.core.app.PendingIntentCompat
import com.geeksville.mesh.util.registerReceiverCompat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.meshtastic.core.common.util.registerReceiverCompat
private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
internal fun UsbManager.requestPermission(
context: Context,
device: UsbDevice,
): Flow<Boolean> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
trySend(granted)
close()
internal fun UsbManager.requestPermission(context: Context, device: UsbDevice): Flow<Boolean> = callbackFlow {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION == intent.action) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
trySend(granted)
close()
}
}
}
}
val permissionIntent = PendingIntentCompat.getBroadcast(
context,
0,
Intent(ACTION_USB_PERMISSION).apply { `package` = context.packageName },
0,
true
)
val permissionIntent =
PendingIntentCompat.getBroadcast(
context,
0,
Intent(ACTION_USB_PERMISSION).apply { `package` = context.packageName },
0,
true,
)
val filter = IntentFilter(ACTION_USB_PERMISSION)
context.registerReceiverCompat(receiver, filter)
requestPermission(device, permissionIntent)

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,7 +14,6 @@
* 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 com.geeksville.mesh.repository.usb
import android.app.Application
@ -22,19 +21,18 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.geeksville.mesh.util.registerReceiverCompat
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialProber
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.registerReceiverCompat
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import javax.inject.Inject
@ -55,11 +53,7 @@ constructor(
) {
private val _serialDevices = MutableStateFlow(emptyMap<String, UsbDevice>())
@Suppress("unused") // Retained as public API
val serialDevices = _serialDevices.asStateFlow()
@Suppress("unused") // Retained as public API
val serialDevicesWithDrivers =
val serialDevices =
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.get()
@ -69,16 +63,6 @@ constructor(
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@Suppress("unused") // Retained as public API
val serialDevicesWithPermission =
_serialDevices
.mapLatest { serialDevices ->
usbManagerLazy.get()?.let { usbManager ->
serialDevices.filterValues { device -> usbManager.hasPermission(device) }
} ?: emptyMap()
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
refreshStateInternal()

View file

@ -24,8 +24,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.MeshServiceNotifications
import javax.inject.Inject

View file

@ -16,8 +16,6 @@
*/
package com.geeksville.mesh.service
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.util.ignoreException
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -25,13 +23,15 @@ import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction

View file

@ -27,14 +27,14 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet

View file

@ -17,12 +17,12 @@
package com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MetadataEntity

View file

@ -16,7 +16,6 @@
*/
package com.geeksville.mesh.service
import com.geeksville.mesh.concurrent.handledLaunch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -24,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.Channel

View file

@ -18,9 +18,7 @@ package com.geeksville.mesh.service
import android.app.Notification
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.meshtastic.core.strings.getString
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -32,10 +30,11 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
@ -44,6 +43,7 @@ import org.meshtastic.core.strings.connected_count
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.getString
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry

View file

@ -20,9 +20,7 @@ import android.util.Log
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.radio.InterfaceId
import com.meshtastic.core.strings.getString
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -32,6 +30,9 @@ import kotlinx.coroutines.flow.first
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.Packet
@ -40,8 +41,6 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
@ -50,6 +49,7 @@ import org.meshtastic.core.service.filter.MessageFilterService
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.critical_alert
import org.meshtastic.core.strings.error_duty_cycle
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.core.strings.waypoint_received
import org.meshtastic.proto.AdminMessage
@ -477,13 +477,12 @@ constructor(
val isAck = routingError == Routing.Error.NONE.value
val p = packetRepository.get().getPacketById(requestId)
val reaction = packetRepository.get().getReactionByPacketId(requestId)
val isMaxRetransmit = routingError == Routing.Error.MAX_RETRANSMIT.value
@Suppress("MaxLineLength")
Logger.d {
val statusInfo = "status=${p?.data?.status ?: reaction?.status}"
"[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
"maxRetransmit=$isMaxRetransmit packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
"packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
}
val m =

View file

@ -20,7 +20,7 @@ import android.util.Log
import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.proto.Data

View file

@ -19,7 +19,6 @@ package com.geeksville.mesh.service
import android.util.Log
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.concurrent.handledLaunch
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -27,11 +26,12 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
@ -39,10 +39,10 @@ import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import java.util.ArrayDeque
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
@Suppress("TooManyFunctions")
@Singleton
@ -125,7 +125,7 @@ constructor(
insertMeshLog(
MeshLog(
uuid = UUID.randomUUID().toString(),
uuid = Uuid.random().toString(),
message_type = type,
received_date = nowMillis,
raw_message = message,
@ -185,7 +185,7 @@ constructor(
val decoded = packet.decoded ?: return
val log =
MeshLog(
uuid = UUID.randomUUID().toString(),
uuid = Uuid.random().toString(),
message_type = "Packet",
received_date = nowMillis,
raw_message = packet.toString(),

View file

@ -17,13 +17,13 @@
package com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import com.meshtastic.core.strings.getString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo

View file

@ -18,13 +18,14 @@ package com.geeksville.mesh.service
import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import okio.ByteString
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.NodeEntity
@ -32,7 +33,6 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel

View file

@ -25,10 +25,8 @@ import android.os.IBinder
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.util.toRemoteExceptions
import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -38,6 +36,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion

View file

@ -41,23 +41,23 @@ import com.geeksville.mesh.R.raw
import com.geeksville.mesh.service.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
import com.geeksville.mesh.service.ReactionReceiver.Companion.REACT_ACTION
import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import com.meshtastic.core.strings.getString
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.low_battery_message
import org.meshtastic.core.strings.low_battery_title
import org.meshtastic.core.strings.mark_as_read

View file

@ -17,19 +17,19 @@
package com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.meshtastic.core.strings.getString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.traceroute_duration
import org.meshtastic.core.strings.traceroute_route_back_to_us
import org.meshtastic.core.strings.traceroute_route_towards_dest

View file

@ -17,7 +17,6 @@
package com.geeksville.mesh.service
import co.touchlab.kermit.Logger
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.Lazy
import kotlinx.coroutines.CompletableDeferred
@ -28,12 +27,13 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.service.ConnectionState
@ -41,13 +41,13 @@ import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
@Suppress("TooManyFunctions")
@Singleton
@ -90,7 +90,7 @@ constructor(
if (packet?.decoded != null) {
val packetToSave =
MeshLog(
uuid = UUID.randomUUID().toString(),
uuid = Uuid.random().toString(),
message_type = "Packet",
received_date = nowMillis,
raw_message = packet.toString(),

View file

@ -18,8 +18,6 @@
package com.geeksville.mesh.ui
import android.Manifest
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
@ -81,7 +79,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.channelsGraph
import com.geeksville.mesh.navigation.connectionsGraph
@ -93,12 +90,11 @@ import com.geeksville.mesh.navigation.settingsGraph
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.connections.DeviceType
import com.geeksville.mesh.ui.connections.ScannerViewModel
import com.geeksville.mesh.ui.connections.components.ConnectionsNavIcon
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
@ -161,10 +157,10 @@ enum class TopLevelDestination(val label: StringResource, val icon: ImageVector,
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanModel = hiltViewModel()) {
fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: ScannerViewModel = hiltViewModel()) {
val navController = rememberNavController()
LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) } }
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
@ -172,16 +168,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
LaunchedEffect(connectionState, notificationPermissionState) {
if (connectionState == ConnectionState.Connected && !notificationPermissionState.status.isGranted) {
notificationPermissionState.launchPermissionRequest()
}
}
}
if (connectionState == ConnectionState.Connected) {
RequestNotificationPermission {
// Nordic handled the trigger for POST_NOTIFICATIONS when connected
}
sharedContactRequested?.let {
SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() })
}

View file

@ -23,7 +23,6 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -36,13 +35,11 @@ import androidx.compose.material.icons.rounded.Language
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -54,7 +51,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.ui.connections.components.BLEDevices
import com.geeksville.mesh.ui.connections.components.ConnectingDeviceInfo
@ -64,7 +60,6 @@ import com.geeksville.mesh.ui.connections.components.EmptyStateContent
import com.geeksville.mesh.ui.connections.components.NetworkDevices
import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.Route
@ -80,7 +75,6 @@ import org.meshtastic.core.strings.must_set_region
import org.meshtastic.core.strings.no_device_selected
import org.meshtastic.core.strings.not_connected
import org.meshtastic.core.strings.set_your_region
import org.meshtastic.core.strings.warning_not_paired
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.TitledCard
@ -91,6 +85,7 @@ import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.Config
import kotlin.uuid.ExperimentalUuidApi
fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
false
@ -105,12 +100,12 @@ fun String?.isValidAddress(): Boolean = if (this.isNullOrBlank()) {
* Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and
* displays connection status.
*/
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalUuidApi::class)
@Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber", "ModifierMissing", "ComposableParamOrder")
@Composable
fun ConnectionsScreen(
connectionsViewModel: ConnectionsViewModel = hiltViewModel(),
scanModel: BTScanModel = hiltViewModel(),
scanModel: ScannerViewModel = hiltViewModel(),
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit,
onNavigateToNodeDetails: (Int) -> Unit,
@ -118,13 +113,10 @@ fun ConnectionsScreen(
) {
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
val scrollState = rememberScrollState()
val scanStatusText by scanModel.errorText.observeAsState("")
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
val scanning by scanModel.spinner.collectAsStateWithLifecycle(false)
val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle()
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
@ -153,14 +145,6 @@ fun ConnectionsScreen(
)
}
// when scanning is true - wait 10000ms and then stop scanning
LaunchedEffect(scanning) {
if (scanning) {
delay(SCAN_PERIOD)
scanModel.stopScan()
}
}
LaunchedEffect(connectionState, regionUnset) {
when (connectionState) {
ConnectionState.Connected -> {
@ -189,13 +173,10 @@ fun ConnectionsScreen(
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier =
Modifier.fillMaxSize()
.verticalScroll(scrollState)
.height(IntrinsicSize.Max)
.padding(paddingValues)
.padding(16.dp),
modifier = Modifier.fillMaxSize().padding(paddingValues).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Spacer(modifier = Modifier.height(4.dp))
val uiState =
when {
connectionState.isConnected() && ourNode != null -> 2
@ -205,11 +186,7 @@ fun ConnectionsScreen(
else -> 0
}
Crossfade(
targetState = uiState,
label = "connection_state",
modifier = Modifier.padding(bottom = 16.dp),
) { state ->
Crossfade(targetState = uiState, label = "connection_state") { state ->
when (state) {
2 -> {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
@ -278,61 +255,47 @@ fun ConnectionsScreen(
selectedDeviceType = it
}
Spacer(modifier = Modifier.height(4.dp))
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (selectedDeviceType) {
DeviceType.BLE -> {
val (bonded, available) = bleDevices.partition { it.bonded }
BLEDevices(
connectionState = connectionState,
bondedDevices = bonded,
availableDevices = available,
selectedDevice = selectedDevice,
scanModel = scanModel,
bluetoothEnabled = bluetoothState.enabled,
)
}
DeviceType.TCP -> {
NetworkDevices(
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
NetworkDevices(
connectionState = connectionState,
discoveredNetworkDevices = discoveredTcpDevices,
recentNetworkDevices = recentTcpDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Spacer(modifier = Modifier.height(16.dp))
}
}
DeviceType.USB -> {
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
UsbDevices(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
scanModel = scanModel,
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Warning Not Paired
val hasShownNotPairedWarning by
connectionsViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle()
val (bonded, _) = bleDevices.partition { it.bonded }
val showWarningNotPaired =
!connectionState.isConnected() && !hasShownNotPairedWarning && bonded.isEmpty()
if (showWarningNotPaired) {
Text(
text = stringResource(Res.string.warning_not_paired),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(Unit) { connectionsViewModel.suppressNoPairedWarning() }
}
}
}
scanStatusText?.let {
@ -354,5 +317,3 @@ fun ConnectionsScreen(
}
}
}
private const val SCAN_PERIOD: Long = 10000 // 10 seconds

View file

@ -17,7 +17,6 @@
package com.geeksville.mesh.ui.connections
import androidx.lifecycle.ViewModel
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -39,7 +38,6 @@ constructor(
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
nodeRepository: NodeRepository,
bluetoothRepository: BluetoothRepository,
private val uiPrefs: UiPrefs,
) : ViewModel() {
@ -52,8 +50,6 @@ constructor(
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
val bluetoothState = bluetoothRepository.state
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning)
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()

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,11 +14,9 @@
* 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 com.geeksville.mesh.ui.connections
import com.geeksville.mesh.model.NO_DEVICE_SELECTED
/** Represent the different ways a device can connect to the phone. */
enum class DeviceType {
BLE,
TCP,

View file

@ -14,18 +14,18 @@
* 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 com.geeksville.mesh.model
package com.geeksville.mesh.ui.connections
import android.app.Application
import android.content.Context
import android.hardware.usb.UsbManager
import android.os.RemoteException
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.model.getMeshtasticShortName
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
import com.geeksville.mesh.repository.radio.RadioInterfaceService
@ -43,6 +43,10 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.util.anonymize
@ -50,11 +54,12 @@ import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.meshtastic
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
@Suppress("LongParameterList", "TooManyFunctions")
class BTScanModel
class ScannerViewModel
@Inject
constructor(
private val application: Application,
@ -65,28 +70,28 @@ constructor(
private val networkRepository: NetworkRepository,
private val radioInterfaceService: RadioInterfaceService,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val nodeRepository: NodeRepository,
private val databaseManager: DatabaseManager,
) : ViewModel() {
private val context: Context
get() = application.applicationContext
val showMockInterface: StateFlow<Boolean>
get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
val showMockInterface: StateFlow<Boolean> = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
private val _errorText = MutableStateFlow<String?>(null)
val errorText: StateFlow<String?> = _errorText.asStateFlow()
private val nodeDb: StateFlow<Map<Int, Node>> = nodeRepository.nodeDBbyNum
val errorText = MutableLiveData<String?>(null)
private val bondedBleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
bluetoothRepository.state
.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private val scannedBleDevicesFlow: StateFlow<List<DeviceListEntry.Ble>> =
bluetoothRepository.scannedDevices
.map { peripherals -> peripherals.map { DeviceListEntry.Ble(it) } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
// Flow for discovered TCP devices, using recent addresses for potential name enrichment
private val processedDiscoveredTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList ->
val recentMap = recentList.associateBy({ it.address }, { it.name })
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
@ -98,7 +103,7 @@ constructor(
shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && !displayName.split("_").none { it == deviceId }) {
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
@ -107,29 +112,57 @@ constructor(
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
/** A combined list of bonded and scanned BLE devices for the UI. */
/** A combined list of bonded BLE devices for the UI. */
val bleDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(bondedBleDevicesFlow, scannedBleDevicesFlow) { bonded, scanned ->
val bondedAddresses = bonded.map { it.fullAddress }.toSet()
val uniqueScanned = scanned.filterNot { it.fullAddress in bondedAddresses }
(bonded + uniqueScanned).sortedBy { it.name }
combine(bondedBleDevicesFlow, nodeDb) { bonded, db ->
bonded
.map { entry: DeviceListEntry.Ble ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
}
.stateInWhileSubscribed(initialValue = emptyList())
private val usbDevicesFlow: StateFlow<List<DeviceListEntry.Usb>> =
usbRepository.serialDevicesWithDrivers
usbRepository.serialDevices
.map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val mockDevice = DeviceListEntry.Mock("Demo Mode")
// Flow for recent TCP devices, filtered to exclude any currently discovered devices
/** UI StateFlow for USB devices. */
val usbDevicesForUi: StateFlow<List<DeviceListEntry>> =
combine(usbDevicesFlow, showMockInterface) { usb, showMock ->
usb + if (showMock) listOf(mockDevice) else emptyList()
val all: List<DeviceListEntry> = usb + if (showMock) listOf(mockDevice) else emptyList()
all.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
nodeDb.value.values.find { node ->
// Hard to match USB to node without connection, but we can try matching by name if it
// follows Meshtastic pattern
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
}
.stateInWhileSubscribed(initialValue = if (showMockInterface.value) listOf(mockDevice) else emptyList())
// Flow for recent TCP devices, filtered to exclude any currently discovered devices
private val filteredRecentTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
combine(recentAddressesDataSource.recentAddresses, processedDiscoveredTcpDevicesFlow) {
recentList,
@ -143,13 +176,47 @@ constructor(
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
private val suffixLength = 4
/** UI StateFlow for discovered TCP devices. */
val discoveredTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
processedDiscoveredTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf())
combine(processedDiscoveredTcpDevicesFlow, networkRepository.resolvedList, nodeDb) { devices, resolved, db ->
devices.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
}
.stateInWhileSubscribed(initialValue = listOf())
/** UI StateFlow for recently connected TCP devices that are not currently discovered. */
val recentTcpDevicesForUi: StateFlow<List<DeviceListEntry>> =
filteredRecentTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf())
combine(filteredRecentTcpDevicesFlow, nodeDb) { devices, db ->
devices.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
// For recent TCP, we don't have the TXT records, but maybe the name contains the ID
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
}
.stateInWhileSubscribed(initialValue = listOf())
val selectedAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
@ -158,35 +225,18 @@ constructor(
.map { it ?: NO_DEVICE_SELECTED }
.stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED)
val spinner: StateFlow<Boolean> = bluetoothRepository.isScanning
init {
serviceRepository.connectionProgress.onEach { errorText.value = it }.launchIn(viewModelScope)
Logger.d { "BTScanModel created" }
serviceRepository.connectionProgress.onEach { _errorText.value = it }.launchIn(viewModelScope)
Logger.d { "ScannerViewModel created" }
}
override fun onCleared() {
super.onCleared()
bluetoothRepository.stopScan()
Logger.d { "BTScanModel cleared" }
Logger.d { "ScannerViewModel cleared" }
}
fun setErrorText(text: String) {
errorText.value = text
}
fun stopScan() {
Logger.d { "stopping scan" }
bluetoothRepository.stopScan()
}
fun refreshPermissions() {
bluetoothRepository.refreshState()
}
fun startScan() {
Logger.d { "starting ble scan" }
bluetoothRepository.startScan()
_errorText.value = text
}
private fun changeDeviceAddress(address: String) {

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,248 +14,87 @@
* 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 com.geeksville.mesh.ui.connections.components
import android.Manifest
import android.content.Intent
import android.os.Build
import android.provider.Settings.ACTION_BLUETOOTH_SETTINGS
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.BluetoothDisabled
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Button
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.launch
import com.geeksville.mesh.ui.connections.ScannerViewModel
import no.nordicsemi.android.common.scanner.rememberFilterState
import no.nordicsemi.android.common.scanner.view.ScannerView
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.bluetooth_available_devices
import org.meshtastic.core.strings.bluetooth_disabled
import org.meshtastic.core.strings.bluetooth_paired_devices
import org.meshtastic.core.strings.grant_permissions
import org.meshtastic.core.strings.no_ble_devices
import org.meshtastic.core.strings.open_settings
import org.meshtastic.core.strings.permission_missing
import org.meshtastic.core.strings.permission_missing_31
import org.meshtastic.core.strings.scan
import org.meshtastic.core.strings.scanning_bluetooth
import org.meshtastic.core.ui.util.showToast
/**
* Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. It handles Bluetooth
* permissions using `accompanist-permissions`.
* permissions and hardware state using Nordic Common Libraries' ScannerView.
*
* @param connectionState The current connection state of the MeshService.
* @param bondedDevices List of discovered BLE devices.
* @param availableDevices
* @param selectedDevice The full address of the currently selected device.
* @param scanModel The ViewModel responsible for Bluetooth scanning logic.
* @param bluetoothEnabled
*/
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BLEDevices(
connectionState: ConnectionState,
bondedDevices: List<DeviceListEntry>,
availableDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,
bluetoothEnabled: Boolean,
) {
LocalContext.current // Used implicitly by stringResource
val isScanning by scanModel.spinner.collectAsStateWithLifecycle(false)
// Define permissions needed for Bluetooth scanning based on Android version.
val bluetoothPermissionsList = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
listOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
}
}
val context = LocalContext.current
val permsMissing =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
stringResource(Res.string.permission_missing_31)
} else {
stringResource(Res.string.permission_missing)
}
val coroutineScope = rememberCoroutineScope()
val singlePermissionState =
rememberPermissionState(
permission = Manifest.permission.ACCESS_BACKGROUND_LOCATION,
onPermissionResult = { granted ->
scanModel.refreshPermissions()
scanModel.startScan()
},
)
val permissionsState =
rememberMultiplePermissionsState(
permissions = bluetoothPermissionsList,
onPermissionsResult = { permissions ->
val granted = permissions.values.all { it }
if (permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false)) {
coroutineScope.launch { context.showToast(permsMissing) }
singlePermissionState.launchPermissionRequest()
}
if (granted) {
scanModel.refreshPermissions()
scanModel.startScan()
} else {
coroutineScope.launch { context.showToast(permsMissing) }
fun BLEDevices(connectionState: ConnectionState, selectedDevice: String, scanModel: ScannerViewModel) {
val filterState =
rememberFilterState(
filter = {
Any {
ServiceUuid(SERVICE_UUID)
Name(Regex(BLE_NAME_PATTERN))
}
},
)
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {
scanModel.refreshPermissions()
scanModel.startScan()
}
Column {
Text(
text = stringResource(Res.string.bluetooth_available_devices),
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 16.dp).fillMaxWidth(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (permissionsState.allPermissionsGranted) {
when {
!bluetoothEnabled -> {
val context = LocalContext.current
EmptyStateContent(
imageVector = Icons.Rounded.BluetoothDisabled,
text = stringResource(Res.string.bluetooth_disabled),
actionButton = {
val intent = Intent(ACTION_BLUETOOTH_SETTINGS)
if (intent.resolveActivity(context.packageManager) != null) {
Button(onClick = { settingsLauncher.launch(intent) }) {
Text(text = stringResource(Res.string.open_settings))
}
}
},
ScannerView(
state = filterState,
onScanResultSelected = { result -> scanModel.onSelected(DeviceListEntry.Ble(result.peripheral)) },
deviceItem = { result ->
val device =
remember(result.peripheral.address, bleDevices) {
bleDevices.find { it.fullAddress == "x${result.peripheral.address}" }
?: DeviceListEntry.Ble(result.peripheral)
}
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
) {
DeviceListItem(
connectionState =
connectionState.takeIf { device.fullAddress == selectedDevice }
?: ConnectionState.Disconnected,
device = device,
onSelect = { scanModel.onSelected(device) },
rssi = result.rssi,
)
}
else -> {
val scanButton: @Composable () -> Unit = {
Button(
enabled = !isScanning,
onClick = { checkPermissionsAndScan(permissionsState, scanModel, true) },
) {
Box {
// Still measure for the icon and text when scanning, so the button's size doesn't jump
// around.
Row(modifier = Modifier.alpha(if (isScanning) 0f else 1f)) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = stringResource(Res.string.scan),
)
Text(stringResource(Res.string.scan))
}
if (isScanning) {
CircularWavyProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
)
}
}
}
}
if (bondedDevices.isEmpty() && availableDevices.isEmpty()) {
EmptyStateContent(
imageVector = Icons.Rounded.BluetoothDisabled,
text =
if (isScanning) {
stringResource(Res.string.scanning_bluetooth)
} else {
stringResource(Res.string.no_ble_devices)
},
actionButton = scanButton,
)
} else {
bondedDevices.DeviceListSection(
title = stringResource(Res.string.bluetooth_paired_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
availableDevices.DeviceListSection(
title = stringResource(Res.string.bluetooth_available_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
scanButton()
}
}
}
} else {
// Show a message and a button to grant permissions if not all granted
EmptyStateContent(
text =
if (permissionsState.shouldShowRationale) {
stringResource(Res.string.permission_missing)
} else {
stringResource(Res.string.permission_missing_31)
},
actionButton = {
Button(onClick = { checkPermissionsAndScan(permissionsState, scanModel, bluetoothEnabled) }) {
Text(text = stringResource(Res.string.grant_permissions))
}
},
)
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
private fun checkPermissionsAndScan(
permissionsState: MultiplePermissionsState,
scanModel: BTScanModel,
bluetoothEnabled: Boolean,
) {
if (permissionsState.allPermissionsGranted && bluetoothEnabled) {
scanModel.startScan()
} else {
permissionsState.launchMultiplePermissionRequest()
},
)
}
}

View file

@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
@ -49,24 +48,28 @@ fun ConnectingDeviceInfo(
onClickDisconnect: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularWavyProgressIndicator(modifier = Modifier.size(40.dp))
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
Column {
Text(text = deviceName, style = MaterialTheme.typography.titleMedium)
Text(text = deviceAddress, style = MaterialTheme.typography.bodySmall)
Text(text = stringResource(Res.string.connecting), style = MaterialTheme.typography.labelSmall)
Text(text = deviceName, style = MaterialTheme.typography.headlineSmall)
Text(text = deviceAddress, style = MaterialTheme.typography.bodyLarge)
Text(
text = stringResource(Res.string.connecting),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
Button(
shape = RectangleShape,
modifier = Modifier.fillMaxWidth().height(40.dp),
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = MaterialTheme.shapes.medium,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.StatusRed,
@ -74,7 +77,7 @@ fun ConnectingDeviceInfo(
),
onClick = onClickDisconnect,
) {
Text(stringResource(Res.string.disconnect))
Text(stringResource(Res.string.disconnect), style = MaterialTheme.typography.titleMedium)
}
}
}

View file

@ -43,6 +43,7 @@ import co.touchlab.kermit.Logger
import com.geeksville.mesh.model.DeviceListEntry
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
@ -51,7 +52,6 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.disconnect
import org.meshtastic.core.strings.firmware_version
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.MaterialBluetoothSignalInfo
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@ -60,8 +60,8 @@ import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.User
import kotlin.time.Duration.Companion.seconds
private const val RSSI_DELAY = 10
private const val RSSI_TIMEOUT = 5
private const val RSSI_DELAY = 2
private const val RSSI_TIMEOUT = 1
@Suppress("LongMethod", "LoopWithTooManyJumpStatements", "TooGenericExceptionCaught")
@Composable
@ -104,7 +104,7 @@ fun CurrentlyConnectedInfo(
) {
MaterialBatteryInfo(level = node.batteryLevel, voltage = node.voltage)
if (bleDevice is DeviceListEntry.Ble) {
MaterialBluetoothSignalInfo(rssi)
RssiIcon(rssi = rssi)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {

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,7 +14,6 @@
* 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 com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.Indication
@ -22,7 +21,10 @@ import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.BluetoothSearching
@ -36,15 +38,23 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import kotlinx.coroutines.delay
import no.nordicsemi.android.common.ui.view.RssiIcon
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
@ -52,6 +62,9 @@ import org.meshtastic.core.strings.add
import org.meshtastic.core.strings.bluetooth
import org.meshtastic.core.strings.network
import org.meshtastic.core.strings.serial
import org.meshtastic.core.ui.component.NodeChip
private const val RSSI_UPDATE_RATE_MS = 2000L
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -62,7 +75,22 @@ fun DeviceListItem(
onSelect: () -> Unit,
modifier: Modifier = Modifier,
onDelete: (() -> Unit)? = null,
rssi: Int? = null,
) {
// Throttle the RSSI updates to match the connected device polling rate
var displayedRssi by remember { mutableIntStateOf(rssi ?: 0) }
LaunchedEffect(rssi) {
if (displayedRssi == 0) {
displayedRssi = rssi ?: 0
}
}
LaunchedEffect(Unit) {
while (true) {
delay(RSSI_UPDATE_RATE_MS)
displayedRssi = rssi ?: 0
}
}
val icon =
when (device) {
is DeviceListEntry.Ble ->
@ -91,31 +119,48 @@ fun DeviceListItem(
val interactionSource = remember { MutableInteractionSource() }
val indication: Indication = LocalIndication.current
ListItem(
modifier =
if (useSelectable && onDelete != null) {
modifier.fillMaxWidth().indication(interactionSource, indication).pointerInput(onDelete) {
detectTapGestures(onTap = { onSelect() }, onLongPress = { onDelete() })
}
} else if (useSelectable) {
modifier.fillMaxWidth().indication(interactionSource, indication).pointerInput(Unit) {
detectTapGestures(onTap = { onSelect() })
val clickableModifier =
if (useSelectable) {
Modifier.indication(interactionSource, indication).pointerInput(device.fullAddress, onDelete) {
detectTapGestures(onTap = { onSelect() }, onLongPress = onDelete?.let { { it() } })
}
} else {
modifier.fillMaxWidth()
},
headlineContent = { Text(device.name) },
leadingContent = { Icon(icon, contentDescription) },
supportingContent = {
if (device is DeviceListEntry.Tcp) {
Text(device.address)
Modifier
}
ListItem(
modifier = modifier.fillMaxWidth().then(clickableModifier).padding(vertical = 4.dp),
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
device.node?.let { node -> NodeChip(node = node) }
?: Text(text = device.name, style = MaterialTheme.typography.titleLarge)
}
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(32.dp),
tint =
if (connectionState.isConnected()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
supportingContent = { Text(text = device.address, style = MaterialTheme.typography.bodyLarge) },
trailingContent = {
if (connectionState.isConnecting()) {
CircularWavyProgressIndicator(modifier = Modifier.size(24.dp))
} else {
RadioButton(selected = connectionState.isConnected(), onClick = null)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (rssi != null) {
RssiIcon(rssi = displayedRssi)
}
if (connectionState.isConnecting()) {
CircularWavyProgressIndicator(modifier = Modifier.size(32.dp))
} else {
RadioButton(selected = connectionState.isConnected(), onClick = null)
}
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),

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,14 +14,21 @@
* 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 com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.TitledCard
@Composable
fun List<DeviceListEntry>.DeviceListSection(
@ -33,16 +40,30 @@ fun List<DeviceListEntry>.DeviceListSection(
onDelete: ((DeviceListEntry) -> Unit)? = null,
) {
if (isNotEmpty()) {
TitledCard(title = title, modifier = modifier) {
forEach { device ->
DeviceListItem(
connectionState =
connectionState.takeIf { device.fullAddress == selectedDevice } ?: ConnectionState.Disconnected,
device = device,
onSelect = { onSelect(device) },
onDelete = onDelete?.let { delete -> { delete(device) } },
modifier = Modifier.Companion,
)
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 8.dp).fillMaxWidth(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
this@DeviceListSection.forEach { device ->
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
) {
DeviceListItem(
connectionState =
connectionState.takeIf { device.fullAddress == selectedDevice }
?: ConnectionState.Disconnected,
device = device,
onSelect = { onSelect(device) },
onDelete = onDelete?.let { delete -> { delete(device) } },
modifier = Modifier,
)
}
}
}
}

View file

@ -47,9 +47,9 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.BTScanModel
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.ui.connections.ScannerViewModel
import com.geeksville.mesh.ui.connections.isValidAddress
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@ -75,7 +75,7 @@ fun NetworkDevices(
discoveredNetworkDevices: List<DeviceListEntry>,
recentNetworkDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,
scanModel: ScannerViewModel,
) {
val searchDialogState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -108,9 +108,33 @@ fun NetworkDevices(
}
}
NetworkDevicesInternal(
connectionState = connectionState,
discoveredNetworkDevices = discoveredNetworkDevices,
recentNetworkDevices = recentNetworkDevices,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
onDelete = { device ->
deviceToDelete = device
showDeleteDialog = true
},
onClickAdd = { showSearchDialog = true },
)
}
@Composable
private fun NetworkDevicesInternal(
connectionState: ConnectionState,
discoveredNetworkDevices: List<DeviceListEntry>,
recentNetworkDevices: List<DeviceListEntry>,
selectedDevice: String,
onSelect: (DeviceListEntry) -> Unit,
onDelete: (DeviceListEntry) -> Unit,
onClickAdd: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val addButton: @Composable () -> Unit = {
Button(onClick = { showSearchDialog = true }) {
Button(onClick = onClickAdd) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(Res.string.add_network_device),
@ -134,11 +158,8 @@ fun NetworkDevices(
title = stringResource(Res.string.recent_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
onDelete = { device ->
deviceToDelete = device
showDeleteDialog = true
},
onSelect = onSelect,
onDelete = onDelete,
)
}
@ -147,7 +168,7 @@ fun NetworkDevices(
title = stringResource(Res.string.discovered_network_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
onSelect = onSelect,
)
}
@ -263,3 +284,23 @@ private fun SearchDialogPreview() {
private fun ConfirmDeleteDialogPreview() {
AppTheme { ConfirmDeleteDialog(fullAddressToDelete = "", onHideDialog = {}, onConfirm = {}) }
}
@PreviewLightDark
@Composable
private fun NetworkDevicesPreview() {
AppTheme {
NetworkDevicesInternal(
connectionState = ConnectionState.Disconnected,
discoveredNetworkDevices = listOf(DeviceListEntry.Tcp("Meshtastic", "t192.168.1.3")),
recentNetworkDevices =
listOf(
DeviceListEntry.Tcp("Home Node", "t192.168.1.100"),
DeviceListEntry.Tcp("Office", "t192.168.1.101"),
),
selectedDevice = "",
onSelect = {},
onDelete = {},
onClickAdd = {},
)
}
}

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,67 +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/>.
*/
package com.geeksville.mesh.ui.connections.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.UsbOff
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.geeksville.mesh.model.BTScanModel
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.ui.connections.ScannerViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.no_usb_devices
import org.meshtastic.core.strings.usb_devices
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun UsbDevices(
connectionState: ConnectionState,
usbDevices: List<DeviceListEntry>,
selectedDevice: String,
scanModel: BTScanModel,
scanModel: ScannerViewModel,
) {
UsbDevicesInternal(
connectionState = connectionState,
usbDevices = usbDevices,
selectedDevice = selectedDevice,
onDeviceSelected = scanModel::onSelected,
)
}
@Composable
private fun UsbDevicesInternal(
connectionState: ConnectionState,
usbDevices: List<DeviceListEntry>,
selectedDevice: String,
onDeviceSelected: (DeviceListEntry) -> Unit,
) {
when {
usbDevices.isEmpty() ->
EmptyStateContent(imageVector = Icons.Rounded.UsbOff, text = stringResource(Res.string.no_usb_devices))
else ->
usbDevices.DeviceListSection(
title = stringResource(Res.string.usb_devices),
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = onDeviceSelected,
if (usbDevices.isEmpty()) {
Column(modifier = Modifier.fillMaxSize()) {
EmptyStateContent(
imageVector = Icons.Rounded.UsbOff,
text = stringResource(Res.string.no_usb_devices),
modifier = Modifier.height(160.dp),
)
}
}
@PreviewLightDark
@Composable
private fun UsbDevicesPreview() {
AppTheme {
UsbDevicesInternal(
connectionState = ConnectionState.Connected,
usbDevices = emptyList(),
selectedDevice = "",
onDeviceSelected = {},
}
} else {
usbDevices.DeviceListSection(
title = "USB",
connectionState = connectionState,
selectedDevice = selectedDevice,
onSelect = scanModel::onSelected,
)
}
}

View file

@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
@ -66,11 +65,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
import org.meshtastic.core.strings.cancel
@ -109,7 +108,7 @@ import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ChannelSet
import kotlin.time.Duration.Companion.days
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalPermissionsApi::class)
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun ContactsScreen(
@ -477,34 +476,31 @@ private fun ContactListViewPaged(
modifier: Modifier = Modifier,
channels: ChannelSet? = null,
) {
val haptics = LocalHapticFeedback.current
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return
val haptic = LocalHapticFeedback.current
Box(modifier = modifier.fillMaxSize()) {
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
ContactListContentInternal(
contacts = contacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
listState = listState,
channels = channels,
haptic = haptic,
)
}
}
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
ContactListContentInternal(
contacts = contacts,
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
listState = listState,
modifier = modifier,
channels = channels,
haptics = haptics,
)
}
@Composable
private fun ContactListContentInternal(
contacts: LazyPagingItems<Contact>,
visiblePlaceholders: List<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
@ -512,10 +508,23 @@ private fun ContactListContentInternal(
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
channels: ChannelSet?,
haptics: HapticFeedback,
haptic: HapticFeedback,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
contactListPlaceholdersItems(
placeholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptic = haptic,
)
contactListPagedItems(
contacts = contacts,
selectedList = selectedList,
@ -524,53 +533,36 @@ private fun ContactListContentInternal(
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptics = haptics,
haptic = haptic,
)
contactListPlaceholdersItems(
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptics = haptics,
)
contactListAppendLoadingItem(contacts = contacts)
contactListAppendLoadingItem(contacts)
}
}
private fun LazyListScope.contactListPlaceholdersItems(
visiblePlaceholders: List<Contact>,
placeholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: ChannelSet?,
haptics: HapticFeedback,
haptic: HapticFeedback,
) {
items(
count = visiblePlaceholders.size,
key = { index -> "placeholder_${visiblePlaceholders[index].contactKey}" },
) { index ->
val placeholder = visiblePlaceholders[index]
val selected by remember { derivedStateOf { selectedList.contains(placeholder.contactKey) } }
val isActive = remember(placeholder.contactKey, activeContactKey) { placeholder.contactKey == activeContactKey }
items(count = placeholders.size, key = { index -> placeholders[index].contactKey }) { index ->
val contact = placeholders[index]
ContactItem(
contact = placeholder,
selected = selected,
isActive = isActive,
onClick = { onClick(placeholder) },
contact = contact,
selected = selectedList.contains(contact.contactKey),
isActive = contact.contactKey == activeContactKey,
onClick = { onClick(contact) },
onLongClick = {
onLongClick(placeholder)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(contact)
},
onNodeChipClick = { onNodeChipClick(contact) },
channels = channels,
onNodeChipClick = { onNodeChipClick(placeholder) },
)
}
}
@ -583,45 +575,31 @@ private fun LazyListScope.contactListPagedItems(
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: ChannelSet?,
haptics: HapticFeedback,
haptic: HapticFeedback,
) {
items(
count = contacts.itemCount,
key = { index ->
val contact = contacts[index]
contact?.let { "${it.contactKey}#$index" } ?: "contact_placeholder_$index"
},
) { index ->
val contact = contacts[index]
if (contact != null) {
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }
val isActive = remember(contact.contactKey, activeContactKey) { contact.contactKey == activeContactKey }
items(count = contacts.itemCount, key = { index -> contacts[index]?.contactKey ?: index }) { index ->
contacts[index]?.let { contact ->
ContactItem(
contact = contact,
selected = selected,
isActive = isActive,
selected = selectedList.contains(contact.contactKey),
isActive = contact.contactKey == activeContactKey,
onClick = { onClick(contact) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(contact)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
channels = channels,
onNodeChipClick = { onNodeChipClick(contact) },
channels = channels,
)
}
}
}
private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems<Contact>) {
contacts.apply {
when {
loadState.append is LoadState.Loading -> {
item(key = "append_loading") {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
if (contacts.loadState.append is LoadState.Loading) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
@ -631,12 +609,7 @@ private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems
private fun rememberVisiblePlaceholders(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
): List<Contact> {
val contactKeys by
remember(contacts.itemCount) {
derivedStateOf { (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet() }
}
return remember(channelPlaceholders, contactKeys) {
channelPlaceholders.filter { placeholder -> !contactKeys.contains(placeholder.contactKey) }
}
): List<Contact> = remember(contacts.itemCount, channelPlaceholders) {
val pagedKeys = (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet()
channelPlaceholders.filter { it.contactKey !in pagedKeys }
}

View file

@ -1,73 +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 com.geeksville.mesh.util
import android.os.RemoteException
import android.util.Log
import co.touchlab.kermit.Logger
object Exceptions {
// / Set in Application.onCreate
var reporter: ((Throwable, String?, String?) -> Unit)? = null
/**
* Report an exception to our analytics provider (if installed - otherwise just log)
*
* After reporting return
*/
fun report(exception: Throwable, tag: String? = null, message: String? = null) {
Logger.e(exception) {
"Exceptions.report: $tag $message"
} // print the message to the log _before_ telling the crash reporter
reporter?.let { r -> r(exception, tag, message) }
}
}
/**
* This wraps (and discards) exceptions, but first it reports them to our bug tracking system and prints a message to
* the log.
*/
fun exceptionReporter(inner: () -> Unit) {
try {
inner()
} catch (ex: Throwable) {
// DO NOT THROW users expect we have fully handled/discarded the exception
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}
/** This wraps (and discards) exceptions, but it does output a log message */
fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
try {
inner()
} catch (ex: Throwable) {
// DO NOT THROW users expect we have fully handled/discarded the exception
if (!silent) Logger.w(ex) { "ignoring exception" }
}
}
// / Convert any exceptions in this service call into a RemoteException that the client can
// / then handle
fun <T> toRemoteExceptions(inner: () -> T): T = try {
inner()
} catch (ex: Throwable) {
Log.e("toRemoteExceptions", "Uncaught exception, returning to remote client", ex)
when (ex) { // don't double wrap remote exceptions
is RemoteException -> throw ex
else -> throw RemoteException(ex.message)
}
}

View file

@ -1,67 +0,0 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.geeksville.mesh.util;
import android.text.InputFilter;
import android.text.Spanned;
/**
* This filter will constrain edits so that the text length is not
* greater than the specified number of bytes using UTF-8 encoding.
*/
public class Utf8ByteLengthFilter implements InputFilter {
private final int mMaxBytes;
public Utf8ByteLengthFilter(int maxBytes) {
mMaxBytes = maxBytes;
}
public CharSequence filter(CharSequence source, int start, int end,
Spanned dest, int dstart, int dend) {
int srcByteCount = 0;
// count UTF-8 bytes in source substring
for (int i = start; i < end; i++) {
char c = source.charAt(i);
srcByteCount += (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3);
}
int destLen = dest.length();
int destByteCount = 0;
// count UTF-8 bytes in destination excluding replaced section
for (int i = 0; i < destLen; i++) {
if (i < dstart || i >= dend) {
char c = dest.charAt(i);
destByteCount += (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3);
}
}
int keepBytes = mMaxBytes - destByteCount;
if (keepBytes <= 0) {
return "";
} else if (keepBytes >= srcByteCount) {
return null; // use original dest string
} else {
// find end position of largest sequence that fits in keepBytes
for (int i = start; i < end; i++) {
char c = source.charAt(i);
keepBytes -= (c < (char) 0x0080) ? 1 : (c < (char) 0x0800 ? 2 : 3);
if (keepBytes < 0) {
return source.subSequence(start, i);
}
}
// If the entire substring fits, we should have returned null
// above, so this line should not be reached. If for some
// reason it is, return null to use the original dest string.
return null;
}
}
}

View file

@ -10,12 +10,12 @@
<path
android:pathData="m17.5564,11.8482 l-5.208,7.6376 -1.5217,-1.0377 5.9674,-8.7512c0.1714,-0.2513 0.4558,-0.4019 0.76,-0.4022 0.3042,-0.0003 0.5889,0.1497 0.7608,0.4008l5.9811,8.7374 -1.5199,1.0404z"
android:strokeLineJoin="round"
android:fillColor="@color/ic_splash"
android:fillColor="@android:color/black"
android:fillType="evenOdd"/>
<path
android:pathData="m5.854,19.4956 l6.3707,-9.3423 -1.5749,-1.0739 -6.3707,9.3423z"
android:strokeLineJoin="round"
android:fillColor="@color/ic_splash"
android:fillColor="@android:color/black"
android:fillType="evenOdd"/>
</group>
</vector>

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Copyright (c) 2025 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/>.
-->
<resources>
<color name="ic_splash">#000000</color>
</resources>

View file

@ -15,20 +15,16 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<!-- Shim in the system UI overrides -->
<style name="SplashTheme.NightAdjusted" parent="Theme.SplashScreen">
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
<!-- Splash screen theme using the Jetpack Splash Screen library -->
<style name="SplashTheme" parent="Theme.SplashScreen">
<!-- Background color for the splash screen -->
<item name="windowSplashScreenBackground">#FFFFFF</item>
</style>
<style name="SplashTheme" parent="SplashTheme.NightAdjusted">
<!-- The splash screen icon -->
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
<!-- The theme to switch to after the splash screen is dismissed -->
<item name="postSplashScreenTheme">@style/Theme.Material3.DynamicColors.DayNight.NoActionBar</item>
</style>
<style name="AppTheme" parent="Theme.Material3.DynamicColors.DayNight.NoActionBar" />
</resources>

View file

@ -35,25 +35,25 @@ import no.nordicsemi.kotlin.ble.client.mock.internal.MockRemoteCharacteristic
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import org.junit.Ignore
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Test
import java.util.UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
@OptIn(ExperimentalCoroutinesApi::class)
class NordicBleInterfaceDrainTest {
private val testDispatcher = StandardTestDispatcher()
private val address = "00:11:22:33:44:55"
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
@Ignore("Flaky: relies on timing in the Nordic BLE mock library which causes intermittent CI failures")
@Test
fun `drainPacketQueueAndDispatch reads multiple packets until empty`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var fromRadioHandle: Int = -1
@ -95,9 +95,9 @@ class NordicBleInterfaceDrainTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -107,18 +107,18 @@ class NordicBleInterfaceDrainTest {
)
fromNumHandle =
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
fromRadioHandle =
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)

View file

@ -38,19 +38,20 @@ import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import org.junit.Before
import org.junit.Test
import java.util.UUID
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
@OptIn(ExperimentalCoroutinesApi::class)
class NordicBleInterfaceRetryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val address = "00:11:22:33:44:55"
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
@Before
fun setup() {
Logger.setLogWriters(
@ -106,10 +107,10 @@ class NordicBleInterfaceRetryTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
toRadioHandle =
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -118,17 +119,17 @@ class NordicBleInterfaceRetryTest {
permission = Permission.WRITE,
)
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -211,10 +212,10 @@ class NordicBleInterfaceRetryTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
toRadioHandle =
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -223,17 +224,17 @@ class NordicBleInterfaceRetryTest {
permission = Permission.WRITE,
)
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)

View file

@ -37,21 +37,23 @@ import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.core.Permission
import no.nordicsemi.kotlin.ble.core.and
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.Before
import org.junit.Test
import java.util.UUID
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
import kotlin.time.Duration.Companion.milliseconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class)
@OptIn(ExperimentalCoroutinesApi::class)
class NordicBleInterfaceTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val address = "00:11:22:33:44:55"
private fun UUID.toKotlinUuid(): Uuid = Uuid.parse(this.toString())
@Before
fun setup() {
Logger.setLogWriters(
@ -66,7 +68,8 @@ class NordicBleInterfaceTest {
@Test
fun `full connection and notification flow`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var fromNumHandle: Int = -1
@ -107,28 +110,28 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
CharacteristicProperty.WRITE and CharacteristicProperty.WRITE_WITHOUT_RESPONSE,
permission = Permission.WRITE,
)
fromNumHandle =
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
fromRadioHandle =
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
logRadioHandle =
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -189,7 +192,8 @@ class NordicBleInterfaceTest {
@Test
fun `handleSendToRadio writes to toRadioCharacteristic`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
val service = mockk<RadioInterfaceService>(relaxed = true)
var toRadioHandle: Int = -1
@ -240,10 +244,10 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
toRadioHandle =
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -257,17 +261,17 @@ class NordicBleInterfaceTest {
}
// Add other required chars to avoid discovery failure
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -308,7 +312,8 @@ class NordicBleInterfaceTest {
@Test
fun `disconnection triggers onDisconnect`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Mock service
val service = mockk<RadioInterfaceService>(relaxed = true)
@ -338,9 +343,9 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -349,17 +354,17 @@ class NordicBleInterfaceTest {
permission = Permission.WRITE,
)
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -401,7 +406,8 @@ class NordicBleInterfaceTest {
@Test
fun `discovery fails if required characteristic missing`() = runTest(testDispatcher) {
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Mock service
val service = mockk<RadioInterfaceService>(relaxed = true)
@ -429,27 +435,27 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
// OMIT toRadio characteristic to force failure
/*
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.WRITE, CharacteristicProperty.WRITE_WITHOUT_RESPONSE),
permission = Permission.WRITE
)
*/
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -481,7 +487,8 @@ class NordicBleInterfaceTest {
@Test
fun `write exception triggers disconnect`() = runTest(testDispatcher) {
val uniqueAddress = "11:22:33:44:55:66"
val centralManager = CentralManager.Factory.mock(scope = backgroundScope)
val mockEnvironment = MockAndroidEnvironment.Api31(isBluetoothEnabled = true)
val centralManager = CentralManager.mock(mockEnvironment, scope = backgroundScope)
// Mock service
val service = mockk<RadioInterfaceService>(relaxed = true)
@ -513,9 +520,9 @@ class NordicBleInterfaceTest {
isBonded = true,
eventHandler = eventHandler,
cachedServices = {
Service(uuid = BleConstants.BTM_SERVICE_UUID.toKotlinUuid()) {
Service(uuid = SERVICE_UUID) {
Characteristic(
uuid = BleConstants.BTM_TORADIO_CHARACTER.toKotlinUuid(),
uuid = TORADIO_CHARACTERISTIC,
properties =
setOf(
CharacteristicProperty.WRITE,
@ -524,17 +531,17 @@ class NordicBleInterfaceTest {
permission = Permission.WRITE,
)
Characteristic(
uuid = BleConstants.BTM_FROMNUM_CHARACTER.toKotlinUuid(),
uuid = FROMNUM_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_FROMRADIO_CHARACTER.toKotlinUuid(),
uuid = FROMRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.READ),
permission = Permission.READ,
)
Characteristic(
uuid = BleConstants.BTM_LOGRADIO_CHARACTER.toKotlinUuid(),
uuid = LOGRADIO_CHARACTERISTIC,
properties = setOf(CharacteristicProperty.NOTIFY),
permission = Permission.READ,
)
@ -561,8 +568,9 @@ class NordicBleInterfaceTest {
// Trigger write which will fail
nordicInterface.handleSendToRadio(byteArrayOf(0x01))
// Wait for error propagation
delay(500.milliseconds)
// Wait for error propagation (retries take time!)
// 3 attempts with 500ms delay between them = ~1000ms+
delay(2500.milliseconds)
// Verify onDisconnect was called with error
verify { service.onDisconnect(any<BleError>()) }

View file

@ -28,8 +28,10 @@ import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MeshServiceBroadcastsTest {
private lateinit var context: Context

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,12 +14,11 @@
* 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 com.geeksville.mesh.ui
import com.geeksville.mesh.model.getInitials
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.core.model.util.getInitials
class UIUnitTest {
@Test

View file

@ -96,34 +96,40 @@ internal fun Project.configureGraphTasks() {
.map { it.split(",").toSet() }
.orElse(setOf("api", "implementation", "baselineProfile", "testedApks"))
val targetProjectPath = path
val dumpTask = tasks.register<GraphDumpTask>("graphDump") {
projectPath.set(this@configureGraphTasks.path)
projectPath.set(targetProjectPath)
val deps = mutableMapOf<String, Set<Pair<String, String>>>()
val projectPlugins = mutableMapOf<String, PluginType>()
projectPlugins[path] = PluginType.entries.firstOrNull { pluginManager.hasPlugin(it.id) } ?: Unknown
val projectDeps = mutableSetOf<Pair<String, String>>()
this@configureGraphTasks.configurations.forEach { config ->
if (config.name in supportedConfigurations.get()) {
dependenciesData.set(providers.provider {
val deps = mutableMapOf<String, Set<Pair<String, String>>>()
val projectDeps = mutableSetOf<Pair<String, String>>()
configurations.filter { it.name in supportedConfigurations.get() }.forEach { config ->
config.dependencies.withType<ProjectDependency>().forEach { dep ->
// Fallback to simpler access or path if available.
val depPath = dep.path
projectDeps.add(config.name to depPath)
projectDeps.add(config.name to dep.path)
}
}
}
deps[path] = projectDeps
dependenciesData.set(deps)
pluginsData.set(projectPlugins)
deps[targetProjectPath] = projectDeps
deps
})
pluginsData.set(providers.provider {
val projectPlugins = mutableMapOf<String, PluginType>()
val type = when {
pluginManager.hasPlugin("meshtastic.android.application") || pluginManager.hasPlugin("meshtastic.android.application.compose") -> PluginType.AndroidApplication
targetProjectPath.startsWith(":feature:") -> PluginType.AndroidFeature
else -> PluginType.entries.firstOrNull { pluginManager.hasPlugin(it.id) } ?: Unknown
}
projectPlugins[targetProjectPath] = type
projectPlugins
})
output.set(layout.buildDirectory.file("mermaid/graph.txt"))
legend.set(layout.buildDirectory.file("mermaid/legend.txt"))
}
tasks.register<GraphUpdateTask>("graphUpdate") {
projectPath.set(this@configureGraphTasks.path)
projectPath.set(targetProjectPath)
input.set(dumpTask.flatMap { it.output })
legend.set(dumpTask.flatMap { it.legend })
output.set(layout.projectDirectory.file("README.md"))
@ -180,6 +186,7 @@ private abstract class GraphDumpTask : DefaultTask() {
appendLine(" direction TB")
appendLine(" L1[Application]:::android-application")
appendLine(" L2[Library]:::android-library")
appendLine(" L3[Feature]:::android-feature")
appendLine(" end")
PluginType.entries.forEach { appendLine("classDef ${it.ref} ${it.style};") }
}
@ -203,7 +210,13 @@ private abstract class GraphUpdateTask : DefaultTask() {
val readme = output.get().asFile
if (!readme.exists()) return
val mermaid = input.get().asFile.readText()
// Update logic...
readme.writeText(readme.readText().replace(Regex("<!--region graph-->.*?<!--endregion-->", DOT_MATCHES_ALL), "<!--region graph-->\n```mermaid\n$mermaid\n```\n<!--endregion-->"))
val currentContent = readme.readText()
val newContent = currentContent.replace(
Regex("<!--region graph-->.*?<!--endregion-->", DOT_MATCHES_ALL),
"<!--region graph-->\n```mermaid\n$mermaid\n```\n<!--endregion-->"
)
if (currentContent != newContent) {
readme.writeText(newContent)
}
}
}

View file

@ -105,9 +105,11 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
freeCompilerArgs.addAll(
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-Xcontext-parameters",
"-Xannotation-default-target=param-property"
"-Xannotation-default-target=param-property",
"-Xskip-prerelease-check"
)
}
}

View file

@ -1,11 +1,25 @@
# `:core:analytics`
## Overview
The `:core:analytics` module provides a unified interface for event tracking and crash reporting. It is designed to strictly separate analytics providers based on the build flavor.
## Key Components
### 1. `PlatformAnalytics`
An interface defining the standard operations for tracking events and reporting errors.
## Flavor Specifics
- **`google` flavor**: Implements `PlatformAnalytics` using **Firebase Analytics** and **Firebase Crashlytics**.
- **`fdroid` flavor**: Provides a "no-op" implementation that does not collect any user data or report crashes, ensuring FOSS compliance.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:analytics[analytics]:::null
:core:analytics[analytics]:::android-library
:core:analytics -.-> :core:prefs
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -1,104 +1,66 @@
# Meshtastic Android API
# `:core:api` (Meshtastic Android API)
This module contains the stable AIDL interface and dependencies required to integrate with the Meshtastic Android app.
## Overview
The `:core:api` module contains the stable AIDL interface and dependencies required for third-party applications to integrate with the Meshtastic Android app.
## Integration
[![](https://jitpack.io/v/meshtastic/Meshtastic-Android.svg)](https://jitpack.io/#meshtastic/Meshtastic-Android)
To communicate with the Meshtastic Android service from your own application, we recommend using **JitPack**.
Add the JitPack repository to your root `build.gradle.kts` (or `settings.gradle.kts`):
```kotlin
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
```
Add the dependencies to your module's `build.gradle.kts`:
### Dependencies
Add the following to your `build.gradle.kts`:
```kotlin
dependencies {
// Replace 'v2.7.13' with the specific version you need
val meshtasticVersion = "v2.7.13"
// The core AIDL interface and Intent constants
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-api:$meshtasticVersion")
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-api:v2.x.x")
// Data models (DataPacket, MeshUser, NodeInfo, etc.)
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-model:$meshtasticVersion")
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-model:v2.x.x")
// Protobuf definitions (PortNum, Telemetry, etc.)
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-proto:$meshtasticVersion")
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-proto:v2.x.x")
}
```
*(Replace `v2.x.x` with the latest stable version).*
## Usage
### 1. Bind to the Service
Use the `IMeshService` interface to bind to the Meshtastic service. It is recommended to query the package manager to find the correct service component, as the package name may vary between build flavors (e.g., Play Store vs. F-Droid).
Use the `IMeshService` interface to bind to the Meshtastic service.
```kotlin
val intent = Intent("com.geeksville.mesh.Service")
val resolveInfo = packageManager.queryIntentServices(intent, 0)
if (resolveInfo.isNotEmpty()) {
val serviceInfo = resolveInfo[0].serviceInfo
intent.setClassName(serviceInfo.packageName, serviceInfo.name)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
// ... query package manager and bind
```
### 2. Interact with the API
Once bound, cast the `IBinder` to `IMeshService`:
```kotlin
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val meshService = IMeshService.Stub.asInterface(service)
// Example: Send a broadcast text message
val packet = DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Hello Meshtastic!".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
id = meshService.packetId,
wantAck = true
)
meshService.send(packet)
}
```
Once bound, cast the `IBinder` to `IMeshService`.
### 3. Register a BroadcastReceiver
Use `MeshtasticIntent` constants for actions. Remember to use `RECEIVER_EXPORTED` on Android 13+.
To receive packets and status updates, register a `BroadcastReceiver`. Use `MeshtasticIntent` constants for the actions.
## Key Components
- **`IMeshService.aidl`**: The primary AIDL interface.
- **`MeshtasticIntent.kt`**: Defines Intent actions for received messages and status changes.
**Important:** On Android 13+ (API 33), you **must** use `RECEIVER_EXPORTED` since you are receiving broadcasts from a different application.
## Module dependency graph
```kotlin
// Using constants from org.meshtastic.core.api.MeshtasticIntent
val intentFilter = IntentFilter().apply {
addAction(MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP)
addAction(MeshtasticIntent.ACTION_NODE_CHANGE)
addAction(MeshtasticIntent.ACTION_CONNECTION_CHANGED)
addAction(MeshtasticIntent.ACTION_MESH_DISCONNECTED)
}
<!--region graph-->
```mermaid
graph TB
:core:api[api]:::android-library
:core:api --> :core:model
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(meshtasticReceiver, intentFilter, Context.RECEIVER_EXPORTED)
} else {
registerReceiver(meshtasticReceiver, intentFilter)
}
```
## Modules
* **`core:api`**: Contains `IMeshService.aidl` and `MeshtasticIntent`.
* **`core:model`**: Contains Parcelable data classes like `DataPacket`, `MeshUser`, `NodeInfo`.
* **`core:proto`**: Contains the generated Protobuf code (Wire).
<!--endregion-->

50
core/barcode/README.md Normal file
View file

@ -0,0 +1,50 @@
# `:core:barcode`
## Overview
The `:core:barcode` module provides barcode and QR code scanning capabilities using Google ML Kit and CameraX. It is used for scanning node configuration, pairing, and contact sharing.
## Key Components
### 1. `BarcodeScanner`
A Composable component that provides a live camera preview and detects barcodes/QR codes in real-time.
- **Technology:** Uses **CameraX** for camera lifecycle management and **ML Kit Barcode Scanning** for detection.
- **Flavors:** Uses the bundled ML Kit library to ensure consistent performance across both `google` and `fdroid` flavors without depending on Google Play Services.
### 2. `BarcodeUtil`
Utility functions for generating and parsing Meshtastic-specific QR codes (e.g., node URLs).
## Usage
The module exposes a scanner that can be integrated into any Compose screen.
```kotlin
BarcodeScanner(
onBarcodeDetected = { barcode ->
// Handle scanned barcode
},
onDismiss = {
// Handle dismiss
}
)
```
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:barcode[barcode]:::android-library
:core:barcode -.-> :core:strings
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<!--endregion-->

86
core/ble/README.md Normal file
View file

@ -0,0 +1,86 @@
# `:core:ble`
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:ble[ble]:::android-library
:core:ble -.-> :core:common
:core:ble -.-> :core:di
:core:ble -.-> :core:model
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<!--endregion-->
## Overview
The `:core:ble` module contains the foundation for Bluetooth Low Energy (BLE) communication in the Meshtastic Android app. It has been modernized to use **Nordic Semiconductor's Android Common Libraries** and **Kotlin BLE Library**.
This modernization replaces legacy callback-based implementations with robust, Coroutine-based architecture, ensuring better stability, maintainability, and standard compliance.
## Key Components
### 1. `NordicBleInterface`
The primary implementation of `IRadioInterface` for BLE devices. It acts as the bridge between the app's `RadioInterfaceService` and the physical Bluetooth device.
- **Responsibility:**
- Managing the connection lifecycle.
- Discovering GATT services and characteristics.
- Handling data transmission (ToRadio) and reception (FromRadio).
- Managing MTU negotiation and connection priority.
### 2. `BluetoothRepository`
A Singleton repository responsible for the global state of Bluetooth on the Android device.
- **Features:**
- **State Management:** Exposes a `StateFlow<BluetoothState>` reflecting whether Bluetooth is enabled, permissions are granted, and which devices are bonded.
- **Scanning:** Uses Nordic's `Scanner` to find devices.
- **Bonding:** Handles the creation of bonds with peripherals.
### 3. `BleConnection`
A wrapper around Nordic's `ClientBleGatt` that simplifies the connection process.
- **Features:**
- **Connection & Await:** Provides suspend functions to connect and wait for a specific connection state.
- **Service Discovery:** Helper functions to discover specific services and characteristics with timeouts and retries.
- **Observability:** Logs connection parameters, PHY updates, and state changes.
### 4. `BleRetry`
A utility for executing BLE operations with exponential backoff and retry logic. This is crucial for handling the inherent unreliability of wireless communication.
## Usage
Dependencies are managed via the version catalog (`libs.versions.toml`).
```toml
[versions]
nordic-ble = "2.0.0-alpha15"
nordic-common = "2.8.2"
[libraries]
nordic-client-android = { module = "no.nordicsemi.kotlin.ble:client-android", version.ref = "nordic-ble" }
# ... other nordic dependencies
```
## Architecture
The module follows a clean architecture approach:
- **Repository Pattern:** `BluetoothRepository` mediates data access.
- **Coroutines & Flow:** All asynchronous operations use Kotlin Coroutines and Flows.
- **Dependency Injection:** Hilt is used for dependency injection.
## Testing
The module includes unit tests for key components, mocking the underlying Nordic libraries to ensure logic correctness without requiring a physical device.

51
core/ble/build.gradle.kts Normal file
View file

@ -0,0 +1,51 @@
/*
* 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/>.
*/
import com.android.build.api.dsl.LibraryExtension
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.hilt)
}
configure<LibraryExtension> { namespace = "org.meshtastic.core.ble" }
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.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.javax.inject)
implementation(libs.kermit)
implementation(libs.kotlinx.coroutines.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)
}

View file

@ -0,0 +1,144 @@
/*
* 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 co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
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 kotlin.uuid.Uuid
private const val SERVICE_DISCOVERY_TIMEOUT_MS = 10_000L
/**
* Encapsulates a BLE connection to a [Peripheral]. Handles connection lifecycle, state monitoring, and service
* discovery.
*
* @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(
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
private val _connectionState = simpleSharedFlow<ConnectionState>()
/** A flow of [ConnectionState] changes for the current [peripheral]. */
val connectionState: SharedFlow<ConnectionState> = _connectionState.asSharedFlow()
private var stateJob: 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) {
stateJob?.cancel()
peripheral = p
centralManager.connect(
peripheral = p,
options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true),
)
stateJob =
p.state
.onEach { state ->
Logger.d { "[$tag] Connection state changed to $state" }
if (state is ConnectionState.Connected) {
p.requestConnectionPriority(ConnectionPriority.HIGH)
observePeripheralDetails(p)
}
_connectionState.emit(state)
}
.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.
* @return The final [ConnectionState].
* @throws kotlinx.coroutines.TimeoutCancellationException if the timeout is reached.
*/
suspend fun connectAndAwait(p: Peripheral, timeoutMs: Long): ConnectionState {
connect(p)
return withTimeout(timeoutMs) {
connectionState.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected }
}
}
/** Discovers characteristics for a specific service with retries. */
@Suppress("ReturnCount")
suspend fun discoverCharacteristics(
serviceUuid: Uuid,
characteristicUuids: List<Uuid>,
): Map<Uuid, RemoteCharacteristic>? = retryBleOperation(tag = tag) {
val p = peripheral ?: return@retryBleOperation null
val services =
withTimeout(SERVICE_DISCOVERY_TIMEOUT_MS) { p.services(listOf(serviceUuid)).filterNotNull().first() }
val service = services.find { it.uuid == serviceUuid } ?: return@retryBleOperation null
val result = mutableMapOf<Uuid, RemoteCharacteristic>()
for (uuid in characteristicUuids) {
val char = service.characteristics.find { it.uuid == uuid }
if (char != null) {
result[uuid] = char
}
}
return@retryBleOperation if (result.size == characteristicUuids.size) result else null
}
private fun observePeripheralDetails(p: Peripheral) {
p.phy.onEach { phy -> Logger.i { "[$tag] BLE PHY changed to $phy" } }.launchIn(scope)
p.connectionParameters
.onEach { params -> Logger.i { "[$tag] BLE connection parameters changed to $params" } }
.launchIn(scope)
}
/** Disconnects from the current peripheral. */
suspend fun disconnect() {
stateJob?.cancel()
stateJob = null
peripheral?.disconnect()
peripheral = null
}
}

View file

@ -14,10 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.repository.radio
package org.meshtastic.core.ble
import com.geeksville.mesh.service.RadioNotConnectedException
import no.nordicsemi.kotlin.ble.client.exception.BluetoothUnavailableException
import no.nordicsemi.kotlin.ble.client.exception.ConnectionFailedException
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
@ -26,6 +24,7 @@ import no.nordicsemi.kotlin.ble.client.exception.ScanningException
import no.nordicsemi.kotlin.ble.client.exception.ValueDoesNotMatchException
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.exception.BluetoothException
import no.nordicsemi.kotlin.ble.core.exception.BluetoothUnavailableException
import no.nordicsemi.kotlin.ble.core.exception.GattException
import no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException
@ -130,9 +129,6 @@ sealed class BleError(val message: String, val shouldReconnect: Boolean) {
else -> BluetoothError(exception)
}
}
is RadioNotConnectedException -> PeripheralNotFound
is ManagerClosedException -> ManagerClosed(exception)
else -> GenericError(exception)
}
}

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 com.geeksville.mesh.repository.bluetooth
package org.meshtastic.core.ble
import android.content.Context
import dagger.Module
@ -28,17 +27,25 @@ import kotlinx.coroutines.Dispatchers
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 javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object BluetoothRepositoryModule {
@Provides
@Singleton
fun provideCentralManager(@ApplicationContext context: Context, coroutineScope: CoroutineScope): CentralManager =
CentralManager.native(context, coroutineScope)
object BleModule {
@Provides
@Singleton
fun provideSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
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(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}

View file

@ -0,0 +1,58 @@
/*
* 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 co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
/**
* Retries a BLE operation a specified number of times with a delay between attempts.
*
* @param count The number of attempts to make.
* @param delayMs The delay in milliseconds between attempts.
* @param tag A tag for logging.
* @param block The operation to perform.
* @return The result of the operation.
* @throws Exception if the operation fails after all attempts.
*/
@Suppress("TooGenericExceptionCaught")
suspend fun <T> retryBleOperation(
count: Int = 3,
delayMs: Long = 500L,
tag: String = "BLE",
block: suspend () -> T,
): T {
var currentAttempt = 0
while (true) {
try {
return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
currentAttempt++
if (currentAttempt >= count) {
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
throw e
}
Logger.w(e) {
"[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
}
delay(delayMs)
}
}
}

View file

@ -0,0 +1,51 @@
/*
* 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 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.
*
* @param centralManager The Nordic [CentralManager] to use for scanning.
*/
class BleScanner @Inject constructor(private val centralManager: CentralManager) {
/**
* 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 }
}

View file

@ -0,0 +1,48 @@
/*
* 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.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import dagger.Lazy
import javax.inject.Inject
/** BroadcastReceiver to handle Bluetooth adapter and device state changes. */
class BluetoothBroadcastReceiver @Inject constructor(private val bluetoothRepository: Lazy<BluetoothRepository>) :
BroadcastReceiver() {
val intentFilter: IntentFilter
get() =
IntentFilter().apply {
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
BluetoothAdapter.ACTION_STATE_CHANGED,
BluetoothDevice.ACTION_BOND_STATE_CHANGED,
-> {
bluetoothRepository.get().refreshState()
}
}
}
}

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,51 +14,37 @@
* 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 com.geeksville.mesh.repository.bluetooth
package org.meshtastic.core.ble
import android.annotation.SuppressLint
import android.app.Application
import android.bluetooth.BluetoothAdapter
import android.os.Build
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.BleConstants.BLE_NAME_PATTERN
import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID
import com.geeksville.mesh.util.registerReceiverCompat
import dagger.Lazy
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.client.distinctByPeripheral
import no.nordicsemi.kotlin.ble.core.Manager
import org.meshtastic.core.common.hasBluetoothPermission
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.toKotlinUuid
/** Repository responsible for maintaining and updating the state of Bluetooth availability. */
@Singleton
class BluetoothRepository
@Inject
constructor(
private val application: Application,
private val bluetoothBroadcastReceiverLazy: Lazy<BluetoothBroadcastReceiver>,
private val dispatchers: CoroutineDispatchers,
@ProcessLifecycle private val processLifecycle: Lifecycle,
private val centralManager: CentralManager,
private val androidEnvironment: AndroidEnvironment,
) {
private val _state =
MutableStateFlow(
@ -70,20 +56,9 @@ constructor(
)
val state: StateFlow<BluetoothState> = _state.asStateFlow()
private val _scannedDevices = MutableStateFlow<List<Peripheral>>(emptyList())
val scannedDevices: StateFlow<List<Peripheral>> = _scannedDevices.asStateFlow()
private val _isScanning = MutableStateFlow(false)
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
private var scanJob: Job? = null
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
updateBluetoothState()
bluetoothBroadcastReceiverLazy.get().let { receiver ->
application.registerReceiverCompat(receiver, receiver.intentFilter)
}
androidEnvironment.bluetoothState.collect { updateBluetoothState() }
}
}
@ -94,43 +69,6 @@ constructor(
/** @return true for a valid Bluetooth address, false otherwise */
fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
/** Starts a BLE scan for Meshtastic devices. The results are published to the [scannedDevices] flow. */
@OptIn(ExperimentalUuidApi::class)
@SuppressLint("MissingPermission")
fun startScan() {
if (isScanning.value) return
scanJob?.cancel()
_scannedDevices.value = emptyList()
scanJob =
processLifecycle.coroutineScope.launch(dispatchers.default) {
centralManager
.scan(5.seconds) { ServiceUuid(BTM_SERVICE_UUID.toKotlinUuid()) }
.distinctByPeripheral()
.map { it.peripheral }
.onStart { _isScanning.value = true }
.onCompletion { _isScanning.value = false }
.catch { ex ->
Logger.w(ex) { "Bluetooth scan failed" }
_isScanning.value = false
}
.collect { peripheral ->
// Add or update the peripheral in our list
val currentList = _scannedDevices.value
_scannedDevices.value =
(currentList.filterNot { it.address == peripheral.address } + peripheral)
}
}
}
/** Stops the currently active BLE scan. */
fun stopScan() {
scanJob?.cancel()
scanJob = null
_isScanning.value = false
}
/**
* 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
@ -146,15 +84,20 @@ constructor(
refreshState()
}
@OptIn(ExperimentalUuidApi::class)
internal suspend fun updateBluetoothState() {
val hasPerms = application.hasBluetoothPermission()
val enabled = centralManager.state.value == Manager.State.POWERED_ON
val hasPerms =
if (androidEnvironment.androidSdkVersion >= Build.VERSION_CODES.S) {
androidEnvironment.isBluetoothScanPermissionGranted &&
androidEnvironment.isBluetoothConnectPermissionGranted
} else {
androidEnvironment.isLocationPermissionGranted
}
val enabled = androidEnvironment.isBluetoothEnabled
val newState =
BluetoothState(
hasPermissions = hasPerms,
enabled = enabled,
bondedDevices = getBondedAppPeripherals(enabled),
bondedDevices = getBondedAppPeripherals(enabled, hasPerms),
)
_state.emit(newState)
@ -162,19 +105,17 @@ constructor(
}
@SuppressLint("MissingPermission")
private fun getBondedAppPeripherals(enabled: Boolean): List<Peripheral> =
if (enabled && application.hasBluetoothPermission()) {
private fun getBondedAppPeripherals(enabled: Boolean, hasPerms: Boolean): List<Peripheral> =
if (enabled && hasPerms) {
centralManager.getBondedPeripherals().filter(::isMatchingPeripheral)
} else {
emptyList()
}
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
@OptIn(ExperimentalUuidApi::class)
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
val hasRequiredService =
peripheral.services(listOf(BTM_SERVICE_UUID.toKotlinUuid())).value?.isNotEmpty() ?: false
val hasRequiredService = peripheral.services(listOf(SERVICE_UUID)).value?.isNotEmpty() ?: false
return nameMatches || hasRequiredService
}

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 com.geeksville.mesh.repository.bluetooth
package org.meshtastic.core.ble
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import org.meshtastic.core.model.util.anonymize

View file

@ -0,0 +1,40 @@
/*
* 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 kotlin.uuid.Uuid
/** Constants for Meshtastic Bluetooth LE interaction. */
object MeshtasticBleConstants {
/** Pattern for Meshtastic device names (e.g., Meshtastic_1234). */
const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$"
/** The Meshtastic service UUID. */
val SERVICE_UUID: Uuid = Uuid.parse("6ba1b218-15a8-461f-9fa8-5dcae273eafd")
/** Characteristic for sending data to the radio. */
val TORADIO_CHARACTERISTIC: Uuid = Uuid.parse("f75c76d2-129e-4dad-a1dd-7866124401e7")
/** Characteristic for receiving packet count notifications. */
val FROMNUM_CHARACTERISTIC: Uuid = Uuid.parse("ed9da18c-a800-4f66-a670-aa7547e34453")
/** Characteristic for reading data from the radio. */
val FROMRADIO_CHARACTERISTIC: Uuid = Uuid.parse("2c55e69e-4993-11ed-b878-0242ac120002")
/** Characteristic for receiving log notifications from the radio. */
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
}

View file

@ -0,0 +1,127 @@
/*
* 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 androidx.lifecycle.Lifecycle
import androidx.lifecycle.testing.TestLifecycleOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.mock.mock
import no.nordicsemi.kotlin.ble.client.mock.AddressType
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpec
import no.nordicsemi.kotlin.ble.client.mock.PeripheralSpecEventHandler
import no.nordicsemi.kotlin.ble.client.mock.Proximity
import no.nordicsemi.kotlin.ble.core.LegacyAdvertisingSetParameters
import no.nordicsemi.kotlin.ble.environment.android.mock.MockAndroidEnvironment
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.di.CoroutineDispatchers
@OptIn(ExperimentalCoroutinesApi::class)
class BluetoothRepositoryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, default = testDispatcher, io = testDispatcher)
private lateinit var mockEnvironment: MockAndroidEnvironment
private lateinit var lifecycleOwner: TestLifecycleOwner
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockEnvironment =
MockAndroidEnvironment.Api31(
isBluetoothEnabled = true,
isBluetoothScanPermissionGranted = true,
isBluetoothConnectPermissionGranted = true,
)
lifecycleOwner =
TestLifecycleOwner(initialState = Lifecycle.State.RESUMED, coroutineDispatcher = testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initial state reflects environment`() = runTest(testDispatcher) {
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
runCurrent()
val state = repository.state.value
assertTrue(state.enabled)
assertTrue(state.hasPermissions)
}
@Test
fun `state updates when bluetooth is disabled`() = runTest(testDispatcher) {
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
mockEnvironment.simulatePowerOff()
runCurrent()
val state = repository.state.value
assertFalse(state.enabled)
}
@Test
fun `bonded devices are correctly identified`() = runTest(testDispatcher) {
val address = "C0:00:00:00:00:03"
val peripheral =
PeripheralSpec.simulatePeripheral(
identifier = address,
addressType = AddressType.RANDOM_STATIC,
proximity = Proximity.IMMEDIATE,
) {
advertising(parameters = LegacyAdvertisingSetParameters(connectable = true)) {
CompleteLocalName("Meshtastic_5678")
}
connectable(
name = "Meshtastic_5678",
isBonded = true,
eventHandler = object : PeripheralSpecEventHandler {},
) {
Service(uuid = SERVICE_UUID) {}
}
}
val centralManager = CentralManager.mock(mockEnvironment, backgroundScope)
centralManager.simulatePeripherals(listOf(peripheral))
val repository = BluetoothRepository(dispatchers, lifecycleOwner.lifecycle, centralManager, mockEnvironment)
repository.refreshState()
runCurrent()
val state = repository.state.value
assertEquals("Should find 1 bonded device", 1, state.bondedDevices.size)
assertEquals(address, state.bondedDevices.first().address)
}
}

View file

@ -1,11 +1,28 @@
# `:core:common`
## Overview
The `:core:common` module contains low-level utility functions, extensions, and common data structures that do not depend on any other Meshtastic-specific modules. It is designed to be highly reusable across the project.
## Key Components
### 1. `util` package
Contains general-purpose extensions and helpers:
- **Coroutines**: Helpers for structured concurrency and Flow transformations.
- **Time**: Utilities for handling timestamps and durations.
- **Exceptions**: Standardized exception types for common error scenarios.
### 2. `ByteUtils.kt`
Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets.
### 3. `BuildConfigProvider.kt`
An interface for accessing build-time configuration in a multiplatform-friendly way.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:common[common]:::null
:core:common[common]:::kmp-library
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -22,7 +22,19 @@ kotlin {
android { androidResources.enable = false }
sourceSets {
androidMain.dependencies { implementation(libs.androidx.core.ktx) }
commonTest.dependencies { implementation(kotlin("test")) }
commonMain.dependencies {
implementation(libs.javax.inject)
implementation(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
implementation(libs.kermit)
}
androidMain.dependencies {
implementation(libs.androidx.core.ktx)
api(libs.nordic.common.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}

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,11 +14,12 @@
* 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.common
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build
@ -73,3 +74,18 @@ fun Context.hasLocationPermission(): Boolean {
val perms = listOf(Manifest.permission.ACCESS_FINE_LOCATION)
return perms.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
}
/**
* Extension for Context to register a BroadcastReceiver in a compatible way across Android versions.
*
* @param receiver The receiver to register.
* @param filter The intent filter.
* @param flag The export flag (defaults to [ContextCompat.RECEIVER_EXPORTED]).
*/
fun Context.registerReceiverCompat(
receiver: BroadcastReceiver,
filter: IntentFilter,
flag: Int = ContextCompat.RECEIVER_EXPORTED,
) {
ContextCompat.registerReceiver(this, receiver, filter, flag)
}

View file

@ -14,18 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.timezone
package org.meshtastic.core.common.util
import org.meshtastic.core.model.util.toPosixString
import java.time.ZoneId
import android.content.Context
import java.io.File
import java.io.FileOutputStream
/**
* Generates a POSIX time zone string from a [ZoneId].
* A specialized [FileOutputStream] that writes data to a file in the application's external files directory. Primarily
* used for low-level protocol debugging and packet logging.
*
* @deprecated Use [org.meshtastic.core.model.util.toPosixString] instead.
* @param context The context used to locate the external files directory.
* @param name The name of the log file.
*/
@Deprecated(
message = "Use org.meshtastic.core.model.util.toPosixString instead",
replaceWith = ReplaceWith("this.toPosixString()", "org.meshtastic.core.model.util.toPosixString"),
)
fun ZoneId.toPosixString(): String = this.toPosixString()
class BinaryLogFile(context: Context, name: String) :
FileOutputStream(File(context.getExternalFilesDir(null), name), true)

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,15 +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 com.geeksville.mesh.android
package org.meshtastic.core.common.util
import android.os.Build
/** Created by kevinh on 1/14/16. */
/** Utility for checking build properties, such as emulator detection. */
object BuildUtils {
// Are we running on the emulator?
val isEmulator
/** Whether the app is currently running on an emulator. */
val isEmulator: Boolean
get() =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||

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 com.geeksville.mesh.util
package org.meshtastic.core.common.util
import android.content.BroadcastReceiver
import android.content.Context
@ -23,25 +22,31 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Parcel
import android.os.Parcelable
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import androidx.core.os.ParcelCompat
/** Reads a [Parcelable] from a [Parcel] in a backward-compatible way. */
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
ParcelCompat.readParcelable(this, loader, T::class.java)
/** Retrieves a [Parcelable] extra from an [Intent] in a backward-compatible way. */
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String?): T? =
IntentCompat.getParcelableExtra(this, key, T::class.java)
/** Retrieves [PackageInfo] for a given package name in a backward-compatible way. */
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
@Suppress("DEPRECATION")
getPackageInfo(packageName, flags)
}
/** Registers a [BroadcastReceiver] using [ContextCompat] to ensure consistent behavior across Android versions. */
fun Context.registerReceiverCompat(
receiver: BroadcastReceiver,
filter: IntentFilter,

View file

@ -0,0 +1,46 @@
/*
* 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.common.util
import android.os.RemoteException
import co.touchlab.kermit.Logger
/**
* Wraps and discards exceptions, but reports them to the crash reporter before logging. Use this for operations that
* should not crash the process but are still unexpected.
*/
fun exceptionReporter(inner: () -> Unit) {
try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}
/**
* Wraps an operation and converts any thrown exceptions into [RemoteException] for safe return through an AIDL
* interface.
*/
fun <T> toRemoteExceptions(inner: () -> T): T = try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Uncaught exception in service call, returning RemoteException to client" }
when (ex) {
is RemoteException -> throw ex
else -> throw RemoteException(ex.message).apply { initCause(ex) }
}
}

View file

@ -0,0 +1,34 @@
/*
* 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.common.util
import java.util.Date
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Awaits the latch for the given [Duration].
*
* @param timeout The maximum time to wait.
* @return `true` if the count reached zero and `false` if the waiting time elapsed before the count reached zero.
*/
fun CountDownLatch.await(timeout: Duration): Boolean = this.await(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
/** Converts this [Instant] to a legacy [Date]. */
fun Instant.toDate(): Date = Date(this.toEpochMilliseconds())

View file

@ -0,0 +1,84 @@
/*
* 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.common.util
import android.text.InputFilter
import android.text.Spanned
/**
* An [InputFilter] that constrains text length based on UTF-8 byte count instead of character count. This is
* particularly useful for fields that must be stored in byte-limited buffers, such as hardware configuration fields.
*
* @param maxBytes The maximum allowed length in UTF-8 bytes.
*/
class Utf8ByteLengthFilter(private val maxBytes: Int) : InputFilter {
private companion object {
const val ONE_BYTE_LIMIT = '\u0080'
const val TWO_BYTE_LIMIT = '\u0800'
const val BYTES_1 = 1
const val BYTES_2 = 2
const val BYTES_3 = 3
}
override fun filter(
source: CharSequence,
start: Int,
end: Int,
dest: Spanned,
dstart: Int,
dend: Int,
): CharSequence? {
val srcByteCount = countUtf8Bytes(source, start, end)
// Calculate bytes in dest excluding the range being replaced
val destLen = dest.length
var destByteCount = 0
destByteCount += countUtf8Bytes(dest, 0, dstart)
destByteCount += countUtf8Bytes(dest, dend, destLen)
var keepBytes = maxBytes - destByteCount
return when {
keepBytes <= 0 -> ""
keepBytes >= srcByteCount -> null
else -> {
for (i in start until end) {
val c = source[i]
keepBytes -= getByteCount(c)
if (keepBytes < 0) {
return source.subSequence(start, i)
}
}
null
}
}
}
private fun countUtf8Bytes(seq: CharSequence, start: Int, end: Int): Int {
var count = 0
for (i in start until end) {
count += getByteCount(seq[i])
}
return count
}
private fun getByteCount(c: Char): Int = when {
c < ONE_BYTE_LIMIT -> BYTES_1
c < TWO_BYTE_LIMIT -> BYTES_2
else -> BYTES_3
}
}

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,33 +14,30 @@
* 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.common.util
package com.geeksville.mesh.concurrent
import com.geeksville.mesh.util.Exceptions
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
private val errorHandler =
CoroutineExceptionHandler { _, exception ->
Exceptions.report(
exception,
"MeshService-coroutine",
"coroutine-exception"
)
}
private val errorHandler = CoroutineExceptionHandler { _, exception ->
Exceptions.report(exception, "coroutine-exception-handler", "Uncaught coroutine exception")
}
/// Wrap launch with an exception handler, FIXME, move into a utility lib
/**
* Launches a new coroutine with a central [CoroutineExceptionHandler] that reports errors to [Exceptions].
*
* @param context Additional to [CoroutineExceptionHandler] context.
* @param start Coroutine start option.
* @param block The coroutine code block.
* @return The launched [Job].
*/
fun CoroutineScope.handledLaunch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
) = this.launch(
context = context + com.geeksville.mesh.concurrent.errorHandler,
start = start,
block = block
)
block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = context + errorHandler, start = start, block = block)

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 com.geeksville.mesh.concurrent
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
@ -29,14 +28,14 @@ import co.touchlab.kermit.Logger
class DeferredExecution {
private val queue = mutableListOf<() -> Unit>()
// / Queue some new work
/** Queues new work to be executed later. */
fun add(fn: () -> Unit) {
queue.add(fn)
}
// / run all work in the queue and clear it to be ready to accept new work
/** Runs all work in the queue and clears it. */
fun run() {
Logger.d { "Running deferred execution numjobs=${queue.size}" }
Logger.d { "Running deferred execution, numJobs=${queue.size}" }
queue.forEach { it() }
queue.clear()
}

View file

@ -0,0 +1,48 @@
/*
* 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.common.util
import co.touchlab.kermit.Logger
object Exceptions {
/** Set by the application to provide a custom crash reporting implementation. */
var reporter: ((Throwable, String?, String?) -> Unit)? = null
/**
* Report an exception to the configured reporter (if any) after logging it.
*
* @param exception The exception to report.
* @param tag An optional tag for the report.
* @param message An optional message providing context.
*/
fun report(exception: Throwable, tag: String? = null, message: String? = null) {
// Log locally first
Logger.e(exception) { "Exceptions.report: $tag $message" }
reporter?.invoke(exception, tag, message)
}
}
/** Wraps and discards exceptions, optionally logging them. */
fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
if (!silent) {
Logger.w(ex) { "Ignoring exception" }
}
}
}

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 com.geeksville.mesh.concurrent
package org.meshtastic.core.common.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@ -23,18 +22,15 @@ import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
/**
* A helper class that manages a single [Job].
*
* When a new job is launched, the previous one is cancelled. This is useful for ensuring that only one operation of a
* certain type is running at a time.
* A helper class that manages a single [Job]. When a new job is launched, the previous one is cancelled. This is useful
* for ensuring that only one operation of a certain type is running at a time.
*/
class SequentialJob @Inject constructor() {
private val job = AtomicReference<Job?>(null)
/**
* Cancels the previous job (if any) and launches a new one in the given [scope].
*
* The new job uses [handledLaunch] to ensure exceptions are reported.
* Cancels the previous job (if any) and launches a new one in the given [scope]. The new job uses [handledLaunch]
* to ensure exceptions are reported.
*/
fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
cancel()

View file

@ -14,36 +14,28 @@
* 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 com.geeksville.mesh.concurrent
import org.meshtastic.core.model.util.nowMillis
package org.meshtastic.core.common.util
/** A deferred execution object (with various possible implementations) */
interface Continuation<in T> {
abstract fun resume(res: Result<T>)
// syntactic sugar
fun resume(res: Result<T>)
/** Syntactic sugar for resuming with success. */
fun resumeSuccess(res: T) = resume(Result.success(res))
fun resumeWithException(ex: Throwable) = try {
resume(Result.failure(ex))
} catch (ex: Throwable) {
// Logger.e { "Ignoring $ex while resuming because we are the ones who threw it" }
throw ex
}
/** Syntactic sugar for resuming with failure. */
fun resumeWithException(ex: Throwable) = resume(Result.failure(ex))
}
/** An async continuation that just calls a callback when the result is available */
/** An async continuation that calls a callback when the result is available. */
class CallbackContinuation<in T>(private val cb: (Result<T>) -> Unit) : Continuation<T> {
override fun resume(res: Result<T>) = cb(res)
}
/**
* This is a blocking/threaded version of coroutine Continuation
* A blocking version of coroutine Continuation using traditional threading primitives.
*
* A little bit ugly, but the coroutine version has a nasty internal bug that showed up in my SyncBluetoothDevice so I
* needed a quick workaround.
* This is useful in contexts where coroutine suspension is not desirable or when bridging with legacy threaded code.
*/
class SyncContinuation<T> : Continuation<T> {
@ -61,7 +53,13 @@ class SyncContinuation<T> : Continuation<T> {
}
}
// Wait for the result (or throw an exception)
/**
* Blocks the current thread until the result is available or the timeout expires.
*
* @param timeoutMsecs Maximum time to wait in milliseconds. If 0, waits indefinitely.
* @return The result of the operation.
* @throws IllegalStateException if a timeout occurs or if an internal error happens.
*/
@Suppress("NestedBlockDepth")
fun await(timeoutMsecs: Long = 0): T {
lock.lock()
@ -70,10 +68,7 @@ class SyncContinuation<T> : Continuation<T> {
while (result == null) {
if (timeoutMsecs > 0) {
val remaining = timeoutMsecs - (nowMillis - startT)
if (remaining <= 0) {
throw Exception("SyncContinuation timeout")
}
// await returns false if waiting time elapsed
check(remaining > 0) { "SyncContinuation timeout" }
condition.await(remaining, java.util.concurrent.TimeUnit.MILLISECONDS)
} else {
condition.await()
@ -81,11 +76,8 @@ class SyncContinuation<T> : Continuation<T> {
}
val r = result
if (r != null) {
return r.getOrThrow()
} else {
throw Exception("This shouldn't happen")
}
checkNotNull(r) { "Unexpected null result in SyncContinuation" }
return r.getOrThrow()
} finally {
lock.unlock()
}
@ -93,17 +85,13 @@ class SyncContinuation<T> : Continuation<T> {
}
/**
* Calls an init function which is responsible for saving our continuation so that some other thread can call resume or
* resume with exception.
* Calls an [initfn] that is responsible for starting an operation and saving the [SyncContinuation]. Then blocks the
* current thread until the operation completes or times out.
*
* Essentially this is a blocking version of the (buggy) coroutine suspendCoroutine
* Essentially a blocking version of [kotlinx.coroutines.suspendCancellableCoroutine].
*/
fun <T> suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation<T>) -> Unit): T {
val cont = SyncContinuation<T>()
// First call the init funct
initfn(cont)
// Now wait for the continuation to finish
return cont.await(timeoutMsecs)
}

View file

@ -14,24 +14,12 @@
* 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.model.util
package org.meshtastic.core.common.util
import kotlinx.datetime.TimeZone
import java.util.Date
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Awaits the latch for the given [Duration].
*
* @param timeout The maximum time to wait.
* @return `true` if the count reached zero and `false` if the waiting time elapsed before the count reached zero.
*/
fun CountDownLatch.await(timeout: Duration): Boolean = this.await(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
/** Accessor for the current time in milliseconds. */
val nowMillis: Long
get() = nowInstant.toEpochMilliseconds()
@ -48,9 +36,6 @@ val nowSeconds: Long
val systemTimeZone: TimeZone
get() = TimeZone.currentSystemDefault()
/** Converts this [Instant] to a legacy [Date]. */
fun Instant.toDate(): Date = Date(this.toEpochMilliseconds())
/** Converts these milliseconds to an [Instant]. */
fun Long.toInstant(): Instant = Instant.fromEpochMilliseconds(this)

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,17 +14,16 @@
* 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 com.geeksville.mesh.concurrent
package org.meshtastic.core.common.util
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertTrue
@ExperimentalCoroutinesApi
@OptIn(ExperimentalCoroutinesApi::class)
class SequentialJobTest {
private val sequentialJob = SequentialJob()
@ -45,7 +44,7 @@ class SequentialJobTest {
}
advanceTimeBy(100)
assertTrue("Job 1 should be active", job1Active)
assertTrue(job1Active, "Job 1 should be active")
// Launch second job
sequentialJob.launch(this) {
@ -53,7 +52,7 @@ class SequentialJobTest {
}
advanceTimeBy(100)
assertTrue("Job 1 should be cancelled", job1Cancelled)
assertTrue(job1Cancelled, "Job 1 should be cancelled")
}
@Test
@ -71,11 +70,11 @@ class SequentialJobTest {
}
advanceTimeBy(100)
assertTrue("Job should be active", jobActive)
assertTrue(jobActive, "Job should be active")
sequentialJob.cancel()
advanceTimeBy(100)
assertTrue("Job should be cancelled", jobCancelled)
assertTrue(jobCancelled, "Job should be cancelled")
}
}

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,18 +14,22 @@
* 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.common.util
package com.geeksville.mesh.android
import kotlin.test.Test
import kotlin.test.assertTrue
import java.util.*
/**
* Created by kevinh on 1/13/16.
*/
object DateUtils {
fun dateUTC(year: Int, month: Int, day: Int): Date {
val cal = GregorianCalendar(TimeZone.getTimeZone("GMT"))
cal.set(year, month, day, 0, 0, 0);
return Date(cal.getTime().getTime())
class TimeUtilsTest {
@Test
fun testNowMillis() {
val start = nowMillis
// Just verify it returns something sensible (not 0)
assertTrue(start > 0)
}
}
@Test
fun testNowSeconds() {
val start = nowSeconds
assertTrue(start > 0)
}
}

View file

@ -1,11 +1,33 @@
# `:core:data`
## Overview
The `:core:data` module implements the Repository pattern, serving as the primary data source for ViewModels in feature modules. It orchestrates data flow between the local database (`core:database`), remote services, and network repositories.
## Key Components
### 1. Repositories
- **`NodeRepository`**: High-level access to node information and mesh state.
- **`MeshLogRepository`**: Access to historical logs and diagnostics.
- **`FirmwareReleaseRepository`**: Manages the discovery and retrieval of firmware updates.
### 2. Data Sources
Internal components that handle raw data fetching from APIs or disk.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:data[data]:::null
:core:data[data]:::android-library
:core:data -.-> :core:analytics
:core:data -.-> :core:common
:core:data -.-> :core:database
:core:data -.-> :core:datastore
:core:data -.-> :core:di
:core:data -.-> :core:model
:core:data -.-> :core:network
:core:data -.-> :core:prefs
:core:data -.-> :core:proto
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View file

@ -27,6 +27,7 @@ configure<LibraryExtension> { namespace = "org.meshtastic.core.data" }
dependencies {
implementation(projects.core.analytics)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.di)

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,15 +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.core.data.model
import kotlinx.serialization.Serializable
import java.util.UUID
import kotlin.uuid.Uuid
@Serializable
data class CustomTileProviderConfig(
val id: String = UUID.randomUUID().toString(),
val id: String = Uuid.random().toString(),
val name: String,
val urlTemplate: String,
)

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