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

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

@ -0,0 +1,135 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import no.nordicsemi.kotlin.ble.client.exception.ConnectionFailedException
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
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
/**
* Represents specific BLE failures, modeled after the iOS implementation's AccessoryError. This allows for more
* granular error handling and intelligent reconnection strategies.
*/
sealed class BleError(val message: String, val shouldReconnect: Boolean) {
/**
* An error indicating that the peripheral was not found. This is a non-recoverable error and should not trigger a
* reconnect.
*/
data object PeripheralNotFound : BleError("Peripheral not found", shouldReconnect = false)
/**
* An error indicating a failure during the connection attempt. This may be recoverable, so a reconnect attempt is
* warranted.
*/
class ConnectionFailed(exception: Throwable) :
BleError("Connection failed: ${exception.message}", shouldReconnect = true)
/**
* An error indicating a failure during the service discovery process. This may be recoverable, so a reconnect
* attempt is warranted.
*/
class DiscoveryFailed(message: String) : BleError("Discovery failed: $message", shouldReconnect = true)
/**
* An error indicating a disconnection initiated by the peripheral. This may be recoverable, so a reconnect attempt
* is warranted.
*/
class Disconnected(reason: ConnectionState.Disconnected.Reason?) :
BleError("Disconnected: ${reason ?: "Unknown reason"}", shouldReconnect = true)
/**
* Wraps a generic GattException. The reconnection strategy depends on the nature of the Gatt error.
*
* @param exception The underlying GattException.
*/
class GattError(exception: GattException) : BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
/**
* Wraps a generic BluetoothException. The reconnection strategy depends on the nature of the Bluetooth error.
*
* @param exception The underlying BluetoothException.
*/
class BluetoothError(exception: BluetoothException) :
BleError("Bluetooth exception: ${exception.message}", shouldReconnect = true)
/** The BLE manager was closed. This is a non-recoverable error. */
class ManagerClosed(exception: ManagerClosedException) :
BleError("Manager closed: ${exception.message}", shouldReconnect = false)
/** A BLE operation failed. This may be recoverable. */
class OperationFailed(exception: OperationFailedException) :
BleError("Operation failed: ${exception.message}", shouldReconnect = true)
/**
* An invalid attribute was used. This usually happens when the GATT handles become stale (e.g. after a service
* change or an unexpected disconnect). This is recoverable via a fresh connection and discovery.
*/
class InvalidAttribute(exception: InvalidAttributeException) :
BleError("Invalid attribute: ${exception.message}", shouldReconnect = true)
/** An error occurred while scanning for devices. This may be recoverable. */
class Scanning(exception: ScanningException) :
BleError("Scanning error: ${exception.message}", shouldReconnect = true)
/** Bluetooth is unavailable on the device. This is a non-recoverable error. */
class BluetoothUnavailable(exception: BluetoothUnavailableException) :
BleError("Bluetooth unavailable: ${exception.message}", shouldReconnect = false)
/** The peripheral is not connected. This may be recoverable. */
class PeripheralNotConnected(exception: PeripheralNotConnectedException) :
BleError("Peripheral not connected: ${exception.message}", shouldReconnect = true)
/** A value did not match what was expected. This may be recoverable. */
class ValueDoesNotMatch(exception: ValueDoesNotMatchException) :
BleError("Value does not match: ${exception.message}", shouldReconnect = true)
/** A generic error for other exceptions that may occur. */
class GenericError(exception: Throwable) :
BleError("An unexpected error occurred: ${exception.message}", shouldReconnect = true)
companion object {
fun from(exception: Throwable): BleError = when (exception) {
is GattException -> {
when (exception) {
is ConnectionFailedException -> ConnectionFailed(exception)
is PeripheralNotConnectedException -> PeripheralNotConnected(exception)
is OperationFailedException -> OperationFailed(exception)
is ValueDoesNotMatchException -> ValueDoesNotMatch(exception)
else -> GattError(exception)
}
}
is BluetoothException -> {
when (exception) {
is BluetoothUnavailableException -> BluetoothUnavailable(exception)
is InvalidAttributeException -> InvalidAttribute(exception)
is ScanningException -> Scanning(exception)
else -> BluetoothError(exception)
}
}
else -> GenericError(exception)
}
}
}

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 android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.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 BleModule {
@Provides
@Singleton
fun provideAndroidEnvironment(@ApplicationContext context: Context): AndroidEnvironment =
NativeAndroidEnvironment.getInstance(context, isNeverForLocationFlagSet = true)
@Provides
@Singleton
fun provideCentralManager(environment: AndroidEnvironment, coroutineScope: CoroutineScope): CentralManager =
CentralManager.native(environment as NativeAndroidEnvironment, coroutineScope)
@Provides
@Singleton
fun provideBleSingletonCoroutineScope(): 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

@ -0,0 +1,122 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.os.Build
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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.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
/** Repository responsible for maintaining and updating the state of Bluetooth availability. */
@Singleton
class BluetoothRepository
@Inject
constructor(
private val dispatchers: CoroutineDispatchers,
@ProcessLifecycle private val processLifecycle: Lifecycle,
private val centralManager: CentralManager,
private val androidEnvironment: AndroidEnvironment,
) {
private val _state =
MutableStateFlow(
BluetoothState(
// Assume we have permission until we get our initial state update to prevent premature
// notifications to the user.
hasPermissions = true,
),
)
val state: StateFlow<BluetoothState> = _state.asStateFlow()
init {
processLifecycle.coroutineScope.launch(dispatchers.default) {
androidEnvironment.bluetoothState.collect { updateBluetoothState() }
}
}
fun refreshState() {
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
}
/** @return true for a valid Bluetooth address, false otherwise */
fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress)
/**
* Initiates bonding with the given peripheral. This is a suspending function that completes when the bonding
* process is finished. After successful bonding, the repository's state is refreshed to include the new bonded
* device.
*
* @param peripheral The peripheral to bond with.
* @throws SecurityException if required Bluetooth permissions are not granted.
* @throws Exception if the bonding process fails.
*/
@SuppressLint("MissingPermission")
suspend fun bond(peripheral: Peripheral) {
peripheral.createBond()
refreshState()
}
internal suspend fun updateBluetoothState() {
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, hasPerms),
)
_state.emit(newState)
Logger.d { "Detected our bluetooth access=$newState" }
}
@SuppressLint("MissingPermission")
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. */
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
val hasRequiredService = peripheral.services(listOf(SERVICE_UUID)).value?.isNotEmpty() ?: false
return nameMatches || hasRequiredService
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import org.meshtastic.core.model.util.anonymize
/** A snapshot in time of the state of the bluetooth subsystem. */
data class BluetoothState(
/** Whether we have adequate permissions to query bluetooth state */
val hasPermissions: Boolean = false,
/** If we have adequate permissions and bluetooth is enabled */
val enabled: Boolean = false,
/** If enabled, a list of the currently bonded devices */
val bondedDevices: List<Peripheral> = emptyList(),
) {
override fun toString(): String =
"BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map {
it.anonymize
}})"
}

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

@ -0,0 +1,31 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.content.Context
import java.io.File
import java.io.FileOutputStream
/**
* 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.
*
* @param context The context used to locate the external files directory.
* @param name The name of the log file.
*/
class BinaryLogFile(context: Context, name: String) :
FileOutputStream(File(context.getExternalFilesDir(null), name), true)

View file

@ -0,0 +1,35 @@
/*
* 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.Build
/** Utility for checking build properties, such as emulator detection. */
object BuildUtils {
/** Whether the app is currently running on an emulator. */
val isEmulator: Boolean
get() =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
Build.FINGERPRINT.contains("emulator") ||
setOf(Build.MODEL, Build.PRODUCT).contains("google_sdk") ||
Build.MODEL.contains("sdk_gphone64") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for") ||
Build.MANUFACTURER.contains("Genymotion") ||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
}

View file

@ -0,0 +1,56 @@
/*
* 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.content.BroadcastReceiver
import android.content.Context
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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION")
getPackageInfo(packageName, flags)
}
/** Registers a [BroadcastReceiver] using [ContextCompat] to ensure consistent behavior across Android versions. */
fun Context.registerReceiverCompat(
receiver: BroadcastReceiver,
filter: IntentFilter,
flag: Int = ContextCompat.RECEIVER_EXPORTED,
) {
ContextCompat.registerReceiver(this, receiver, filter, flag)
}

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

@ -0,0 +1,43 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
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, "coroutine-exception-handler", "Uncaught coroutine exception")
}
/**
* 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,
): Job = launch(context = context + errorHandler, start = start, block = block)

View file

@ -0,0 +1,42 @@
/*
* 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
/**
* Sometimes when starting services we face situations where messages come in that require computation but we can't do
* that computation yet because we are still waiting for some long running init to complete.
*
* This class lets you queue up closures to run at a later date and later on you can call run() to run all the
* previously queued work.
*/
class DeferredExecution {
private val queue = mutableListOf<() -> Unit>()
/** Queues new work to be executed later. */
fun add(fn: () -> Unit) {
queue.add(fn)
}
/** Runs all work in the queue and clears it. */
fun run() {
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

@ -0,0 +1,47 @@
/*
* 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
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.
*/
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.
*/
fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
cancel()
val newJob = scope.handledLaunch(block = block)
job.set(newJob)
newJob.invokeOnCompletion { job.compareAndSet(newJob, null) }
}
/** Cancels the current job. */
fun cancel() {
job.getAndSet(null)?.cancel()
}
}

View file

@ -0,0 +1,97 @@
/*
* 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
/** A deferred execution object (with various possible implementations) */
interface Continuation<in T> {
fun resume(res: Result<T>)
/** Syntactic sugar for resuming with success. */
fun resumeSuccess(res: T) = resume(Result.success(res))
/** Syntactic sugar for resuming with failure. */
fun resumeWithException(ex: Throwable) = resume(Result.failure(ex))
}
/** 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)
}
/**
* A blocking version of coroutine Continuation using traditional threading primitives.
*
* This is useful in contexts where coroutine suspension is not desirable or when bridging with legacy threaded code.
*/
class SyncContinuation<T> : Continuation<T> {
private val lock = java.util.concurrent.locks.ReentrantLock()
private val condition = lock.newCondition()
private var result: Result<T>? = null
override fun resume(res: Result<T>) {
lock.lock()
try {
result = res
condition.signal()
} finally {
lock.unlock()
}
}
/**
* 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()
try {
val startT = nowMillis
while (result == null) {
if (timeoutMsecs > 0) {
val remaining = timeoutMsecs - (nowMillis - startT)
check(remaining > 0) { "SyncContinuation timeout" }
condition.await(remaining, java.util.concurrent.TimeUnit.MILLISECONDS)
} else {
condition.await()
}
}
val r = result
checkNotNull(r) { "Unexpected null result in SyncContinuation" }
return r.getOrThrow()
} finally {
lock.unlock()
}
}
}
/**
* 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 a blocking version of [kotlinx.coroutines.suspendCancellableCoroutine].
*/
fun <T> suspend(timeoutMsecs: Long = -1, initfn: (SyncContinuation<T>) -> Unit): T {
val cont = SyncContinuation<T>()
initfn(cont)
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

@ -0,0 +1,80 @@
/*
* 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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class SequentialJobTest {
private val sequentialJob = SequentialJob()
@Test
fun `launch cancels previous job`() = runTest {
var job1Active = false
var job1Cancelled = false
// Launch first job
sequentialJob.launch(this) {
try {
job1Active = true
delay(1000)
} finally {
job1Cancelled = true
}
}
advanceTimeBy(100)
assertTrue(job1Active, "Job 1 should be active")
// Launch second job
sequentialJob.launch(this) {
// Do nothing
}
advanceTimeBy(100)
assertTrue(job1Cancelled, "Job 1 should be cancelled")
}
@Test
fun `cancel stops the job`() = runTest {
var jobActive = false
var jobCancelled = false
sequentialJob.launch(this) {
try {
jobActive = true
delay(1000)
} finally {
jobCancelled = true
}
}
advanceTimeBy(100)
assertTrue(jobActive, "Job should be active")
sequentialJob.cancel()
advanceTimeBy(100)
assertTrue(jobCancelled, "Job should be cancelled")
}
}

View file

@ -0,0 +1,35 @@
/*
* 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 kotlin.test.Test
import kotlin.test.assertTrue
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,
)

View file

@ -18,6 +18,7 @@ package org.meshtastic.core.data.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
@ -27,7 +28,6 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -19,6 +19,7 @@ package org.meshtastic.core.data.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
import org.meshtastic.core.database.entity.FirmwareRelease
@ -26,7 +27,6 @@ import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -25,11 +25,11 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo

View file

@ -23,12 +23,12 @@ import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
class DeviceHardwareRepositoryTest {

View file

@ -28,6 +28,7 @@ import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.MeshtasticDatabase
@ -35,7 +36,6 @@ import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.proto.Data
import org.meshtastic.proto.EnvironmentMetrics
@ -43,7 +43,7 @@ import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.UUID
import kotlin.uuid.Uuid
class MeshLogRepositoryTest {
@ -73,7 +73,7 @@ class MeshLogRepositoryTest {
val meshLog =
MeshLog(
uuid = UUID.randomUUID().toString(),
uuid = Uuid.random().toString(),
message_type = "telemetry",
received_date = nowMillis,
raw_message = "",
@ -100,7 +100,7 @@ class MeshLogRepositoryTest {
val meshLog =
MeshLog(
uuid = UUID.randomUUID().toString(),
uuid = Uuid.random().toString(),
message_type = "telemetry",
received_date = nowMillis,
raw_message = "",

View file

@ -25,7 +25,12 @@ The `NodeInfoDao` implements specific logic to protect against impersonation and
<!--region graph-->
```mermaid
graph TB
:core:database[database]:::null
:core:database[database]:::android-library
:core:database -.-> :core:common
:core:database -.-> :core:di
:core:database -.-> :core:model
:core:database -.-> :core:proto
:core:database -.-> :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;

View file

@ -32,6 +32,7 @@ configure<LibraryExtension> {
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.proto)

View file

@ -31,13 +31,13 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.PortNum
@RunWith(AndroidJUnit4::class)

View file

@ -33,8 +33,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import java.io.File
import java.security.MessageDigest
import javax.inject.Inject
@ -42,6 +42,7 @@ import javax.inject.Singleton
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Singleton
@Suppress("TooManyFunctions")
class DatabaseManager @Inject constructor(private val app: Application, private val dispatchers: CoroutineDispatchers) {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
@ -115,6 +116,13 @@ class DatabaseManager @Inject constructor(private val app: Application, private
/** Execute [block] with the current DB instance. */
inline fun <T> withDb(block: (MeshtasticDatabase) -> T): T = block(currentDb.value)
/** Returns true if a database exists for the given device address. */
fun hasDatabaseFor(address: String?): Boolean {
if (address.isNullOrBlank() || address == "n") return false
val dbName = buildDbName(address)
return getDbFile(app, dbName) != null
}
private fun markLastUsed(dbName: String) {
prefs.edit().putLong(lastUsedKey(dbName), nowMillis).apply()
}

View file

@ -25,13 +25,13 @@ import androidx.room.Update
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import okio.ByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.PacketEntity
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.ChannelSettings
@Suppress("TooManyFunctions")

View file

@ -20,9 +20,9 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.util.nowMillis
@Serializable
@Entity(tableName = "device_hardware")

View file

@ -20,9 +20,9 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.NetworkFirmwareRelease
import org.meshtastic.core.model.util.nowMillis
@Serializable
@Entity(tableName = "firmware_release")

View file

@ -24,14 +24,14 @@ import androidx.room.PrimaryKey
import androidx.room.Relation
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.database.model.Node
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel

View file

@ -23,12 +23,12 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import okio.ByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.getShortDateTime
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.User
data class PacketEntity(

View file

@ -27,11 +27,11 @@ import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
@ -150,7 +150,7 @@ class MigrationTest {
}
private suspend fun insertPacket(channel: Int, text: String) {
packetDao.insert(
val packet =
Packet(
uuid = 0L,
myNodeNum = 42424242,
@ -159,8 +159,8 @@ class MigrationTest {
received_time = nowMillis,
read = false,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = channel, text = text),
),
)
)
packetDao.insert(packet)
}
private suspend fun getAllPackets() = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first()

View file

@ -1,11 +1,25 @@
# `:core:datastore`
## Overview
The `:core:datastore` module manages structured, asynchronous data storage using **Jetpack DataStore**. It is primarily used for storing complex configuration objects like radio channel sets and local device configurations.
## Key Components
### 1. Data Sources
- **`ChannelSetDataSource`**: Manages the storage of radio channel configurations.
- **`RecentAddressesDataSource`**: Stores a list of recently connected radio addresses (BLE/USB/TCP).
- **`UiPreferencesDataSource`**: Modern replacement for `SharedPreferences` for UI-related settings.
### 2. Serializers
Uses **Kotlin Serialization** to convert between Protobuf/JSON and the underlying DataStore storage.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:datastore[datastore]:::null
:core:datastore[datastore]:::android-library
:core:datastore -.-> :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

@ -1,11 +1,25 @@
# `:core:di`
## Overview
The `:core:di` module defines the core Dagger Hilt modules and provides standard dependencies that are shared across all other modules.
## Key Components
### 1. `AppModule.kt`
Defines bindings for application-wide singletons like `Application`, `Context`, and `Resources`.
### 2. `CoroutineDispatchers.kt`
Provides a wrapper for standard Kotlin `CoroutineDispatchers` (`IO`, `Default`, `Main`), allowing for easy mocking in unit tests.
### 3. `ProcessLifecycle.kt`
Exposes the application's global process lifecycle as a Hilt binding, enabling components to react to the app entering the foreground or background.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:di[di]:::null
:core:di[di]:::android-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

@ -1,11 +1,30 @@
# `:core:model`
## Overview
The `:core:model` module contains the domain models and Parcelable data classes used throughout the application and its API. These models are designed to be shared between the service and client applications via AIDL.
## Key Models
- **`DataPacket`**: Represents a mesh packet (text, telemetry, etc.).
- **`NodeInfo`**: Contains detailed information about a node (position, SNR, battery, etc.).
- **`DeviceHardware`**: Represents supported Meshtastic hardware devices and their capabilities.
- **`Channel`**: Represents a mesh channel configuration.
## Usage
This module is a core dependency of `core:api` and most feature modules.
```kotlin
implementation(projects.core.model)
```
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:model[model]:::null
:core:model[model]:::android-library
:core:model --> :core:proto
:core:model --> :core:common
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;
@ -19,22 +38,3 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<!--endregion-->
## Meshtastic Core Models
This module contains the Parcelable data classes used by the Meshtastic Android app and its API. These models are designed to be shared between the service and client applications via AIDL.
### Key Classes
* **`DataPacket`**: Represents a mesh packet (text, telemetry, etc.).
* **`MeshUser`**: Represents a user/node on the mesh.
* **`NodeInfo`**: Contains detailed information about a node (position, SNR, battery, etc.).
* **`Position`**: GPS location data.
### Usage
This module is typically used as a dependency of `core:api` but can be used independently if you need to work with Meshtastic data structures.
```kotlin
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-model:v2.7.13")
```

View file

@ -55,6 +55,7 @@ afterEvaluate {
dependencies {
api(projects.core.proto)
api(projects.core.common)
api(libs.androidx.annotation)
api(libs.kotlinx.serialization.json)

View file

@ -25,9 +25,9 @@ import kotlinx.parcelize.TypeParceler
import kotlinx.serialization.Serializable
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Waypoint

View file

@ -19,10 +19,10 @@ package org.meshtastic.core.model
import android.graphics.Color
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.bearing
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel

View file

@ -16,6 +16,9 @@
*/
package org.meshtastic.core.model.util
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY
import java.text.DateFormat
import kotlin.time.Duration

View file

@ -86,3 +86,35 @@ fun Telemetry.hasValidEnvironmentMetrics(): Boolean {
val metrics = this.environment_metrics ?: return false
return metrics.relative_humidity != null && metrics.temperature != null && !metrics.temperature!!.isNaN()
}
/**
* 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.
*/
@Suppress("MagicNumber")
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)
}
fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }

View file

@ -21,6 +21,9 @@ import androidx.annotation.RequiresApi
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.toLocalDateTime
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.systemTimeZone
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime

View file

@ -20,6 +20,12 @@ import kotlinx.datetime.TimeZone
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.common.util.await
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.secondsToInstant
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import java.util.concurrent.CountDownLatch
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds

View file

@ -1,11 +1,32 @@
# `:core:navigation`
## Overview
The `:core:navigation` module defines the type-safe navigation structure for the entire application using Kotlin Serialization and the Jetpack Navigation library.
## Key Components
### 1. `Routes.kt`
Contains all the serializable classes and objects that represent destinations in the app.
## Features
- **Type-Safety**: Leverages Kotlin Serialization to pass data between screens without fragile Bundle keys.
- **Centralized Definition**: All routes are defined in one place to prevent circular dependencies between feature modules.
## Usage
Feature modules depend on this module to define their entry points and navigate to other features.
```kotlin
import org.meshtastic.core.navigation.MessagingRoutes
navController.navigate(MessagingRoutes.Chat(nodeId = 12345))
```
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:navigation[navigation]:::null
:core:navigation[navigation]:::android-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

@ -1,11 +1,25 @@
# `:core:network`
## Overview
The `:core:network` module handles all internet-based communication, including fetching firmware metadata, device hardware definitions, and map tiles (in the `fdroid` flavor).
## Key Components
### 1. `Ktor` Client
The module uses **Ktor** as its primary HTTP client for high-performance, asynchronous networking.
### 2. Remote Data Sources
- **`FirmwareReleaseRemoteDataSource`**: Fetches the latest firmware versions from GitHub or Meshtastic's metadata servers.
- **`DeviceHardwareRemoteDataSource`**: Fetches definitions for supported Meshtastic hardware devices.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:network[network]:::null
:core:network[network]:::android-library
:core:network -.-> :core:di
:core:network -.-> :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;

29
core/nfc/README.md Normal file
View file

@ -0,0 +1,29 @@
# `:core:nfc`
## Overview
The `:core:nfc` module provides Near Field Communication (NFC) capabilities for the application. It is primarily used for quick pairing or sharing configuration between devices.
## Key Components
### 1. `NfcScanner`
A component that manages NFC adapter state and listens for NFC tags or NDEF messages.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:nfc[nfc]:::android-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;
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-->

View file

@ -1,11 +1,24 @@
# `:core:prefs`
## Overview
The `:core:prefs` module provides a type-safe wrapper around `SharedPreferences` for managing application and radio configuration preferences.
## Key Components
### 1. `PrefDelegate.kt`
Uses Kotlin property delegates to simplify reading and writing preferences.
### 2. Specialized Prefs
- **`RadioPrefs`**: Manages radio-specific settings (e.g., the last connected device address).
- **`UiPrefs`**: Manages UI preferences (e.g., theme selection, unit systems).
- **`MapPrefs`**: Manages mapping preferences (e.g., preferred map provider).
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:prefs[prefs]:::null
:core:prefs[prefs]:::android-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

@ -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 org.meshtastic.core.prefs.analytics
import android.content.SharedPreferences
@ -25,9 +24,9 @@ import org.meshtastic.core.prefs.NullableStringPrefDelegate
import org.meshtastic.core.prefs.PrefDelegate
import org.meshtastic.core.prefs.di.AnalyticsSharedPreferences
import org.meshtastic.core.prefs.di.AppSharedPreferences
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
/** Interface for managing analytics-related preferences. */
interface AnalyticsPrefs {
@ -66,7 +65,7 @@ constructor(
private var _installId: String? by NullableStringPrefDelegate(appPrefs, "appPrefs_install_id", null)
override val installId: String
get() = _installId ?: UUID.randomUUID().toString().also { _installId = it }
get() = _installId ?: Uuid.random().toString().also { _installId = it }
override fun getAnalyticsAllowedChangesFlow(): Flow<Boolean> = callbackFlow {
val listener =

View file

@ -1,11 +1,27 @@
# `:core:proto`
## Overview
This module contains the generated Kotlin and Java code from the Meshtastic Protobuf definitions. It uses the [Wire](https://github.com/square/wire) library for efficient and clean model generation.
## Key Components
- **`PortNum`**: Defines the identification for different types of data payloads.
- **`MeshPacket`**: The core protocol message definition.
- **Protobuf Modules**: Definitions for telemetry, position, administration, and more.
## Usage
This module is a low-level dependency for any module that needs to encode or decode Meshtastic protocol data.
```kotlin
implementation(projects.core.proto)
```
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:proto[proto]:::null
:core:proto[proto]:::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;
@ -19,21 +35,3 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<!--endregion-->
## Meshtastic Protobuf Definitions
This module contains the generated Kotlin and Java code from the Meshtastic Protobuf definitions. It uses the [Wire](https://github.com/square/wire) library for efficient and clean model generation.
### Key Components
* **Port Numbers**: Defines the `PortNum` enum for identifying different types of data payloads.
* **Mesh Protocol**: Contains the core `MeshPacket` and protocol message definitions.
* **Modules**: Includes definitions for telemetry, position, administration, and more.
### Usage
This module is typically used as a dependency of `core:api` and `core:model`.
```kotlin
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-proto:v2.7.13")
```

View file

@ -1,11 +1,34 @@
# `:core:service`
## Overview
The `:core:service` module contains the abstractions and client-side logic for interacting with the main Meshtastic Android Service.
## Key Components
### 1. `ServiceClient`
The main entry point for other parts of the app (or third-party apps) to bind to and interact with the mesh service via AIDL.
### 2. `ServiceRepository`
A high-level repository that wraps the service connection and exposes reactive `Flow`s for connection status and data arrival.
### 3. `ConnectionState`
An enum representing the current state of the radio connection (`Connected`, `Disconnected`, `DeviceSleep`, etc.).
### 4. `ServiceAction`
Defines Intent actions for starting, stopping, and interacting with the background service.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:service[service]:::null
:core:service[service]:::android-library
:core:service --> :core:api
:core:service -.-> :core:common
:core:service -.-> :core:database
:core:service -.-> :core:model
:core:service -.-> :core:prefs
:core:service -.-> :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> {
dependencies {
api(projects.core.api)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.prefs)

View file

@ -0,0 +1,145 @@
/*
* 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.service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.os.IInterface
import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import org.meshtastic.core.common.util.exceptionReporter
import java.io.Closeable
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class BindFailedException : Exception("bindService failed")
/**
* A generic helper for binding to an Android Service via AIDL. Handles connection lifecycle, thread safety for initial
* binding, and automatic retry for common race conditions.
*
* @param T The type of the AIDL interface.
* @param stubFactory A factory function to convert an [IBinder] to the interface type.
*/
open class ServiceClient<T : IInterface>(private val stubFactory: (IBinder) -> T) : Closeable {
private companion object {
const val BIND_RETRY_DELAY_MS = 500L
}
/** The currently bound service instance, or null if not connected. */
var serviceP: T? = null
/**
* Returns the bound service instance. If not currently connected, this will block the current thread until the
* connection is established.
*
* @throws IllegalStateException If [connect] has not been called.
* @throws IllegalStateException If the service is not bound after waiting.
*/
val service: T
get() {
waitConnect()
return checkNotNull(serviceP) { "Service not bound" }
}
private var context: Context? = null
private var isClosed = true
private val lock = ReentrantLock()
private val condition = lock.newCondition()
/**
* Blocks the current thread until the service is connected.
*
* @throws IllegalStateException If [connect] has not been called.
*/
fun waitConnect() {
lock.withLock {
check(context != null) { "Connect must be called before waitConnect" }
if (serviceP == null) {
condition.await()
}
}
}
/**
* Initiates a binding to the service.
*
* @param c The context to use for binding.
* @param intent The intent used to identify the service.
* @param flags Binding flags (e.g., [Context.BIND_AUTO_CREATE]).
* @throws BindFailedException If the initial bind call fails twice.
*/
suspend fun connect(c: Context, intent: Intent, flags: Int) {
context = c
if (isClosed) {
isClosed = false
if (!c.bindService(intent, connection, flags)) {
// Handle potential race condition on quick re-bind
Logger.w { "Initial bind failed, retrying after delay..." }
delay(BIND_RETRY_DELAY_MS)
if (!c.bindService(intent, connection, flags)) {
throw BindFailedException()
}
}
} else {
Logger.w { "Ignoring rebind attempt for already active service connection" }
}
}
override fun close() {
isClosed = true
try {
context?.unbindService(connection)
} catch (ex: IllegalArgumentException) {
Logger.w(ex) { "Ignoring error during unbind: service might have already been cleaned up" }
}
serviceP = null
context = null
}
/** Called on the main thread when the service is connected. */
open fun onConnected(service: T) {}
/** Called on the main thread when the service connection is lost. */
open fun onDisconnected() {}
private val connection =
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) = exceptionReporter {
if (!isClosed) {
val s = stubFactory(binder)
serviceP = s
onConnected(s)
lock.withLock { condition.signalAll() }
} else {
Logger.w { "Service connected after close was called; ignoring stale connection" }
}
}
override fun onServiceDisconnected(name: ComponentName?) = exceptionReporter {
serviceP = null
onDisconnected()
}
}
}

View file

@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.os.IInterface
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.fail
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
@OptIn(ExperimentalCoroutinesApi::class)
class ServiceClientTest {
interface MyInterface : IInterface
private val stubFactory: (IBinder) -> MyInterface = { _ -> mockk<MyInterface>() }
private val client = ServiceClient(stubFactory)
private val context = mockk<Context>(relaxed = true)
private val intent = mockk<Intent>()
private val binder = mockk<IBinder>()
@Test
fun `connect binds service successfully`() = runTest {
val slot = slot<ServiceConnection>()
every { context.bindService(any<Intent>(), capture(slot), any<Int>()) } returns true
client.connect(context, intent, 0)
verify { context.bindService(intent, any<ServiceConnection>(), 0) }
// Simulate connection
if (slot.isCaptured) {
slot.captured.onServiceConnected(ComponentName("pkg", "cls"), binder)
assertNotNull(client.serviceP)
} else {
fail("ServiceConnection was not captured")
}
}
@Test
fun `connect retries on failure`() = runTest {
val slot = slot<ServiceConnection>()
// First attempt fails, second succeeds
every { context.bindService(any<Intent>(), capture(slot), any<Int>()) } returnsMany listOf(false, true)
client.connect(context, intent, 0)
verify(exactly = 2) { context.bindService(intent, any<ServiceConnection>(), 0) }
}
@Test(expected = BindFailedException::class)
fun `connect throws exception after two failures`() = runTest {
every { context.bindService(any<Intent>(), any<ServiceConnection>(), any<Int>()) } returns false
client.connect(context, intent, 0)
}
@Test
fun `waitConnect blocks until connected`() {
val slot = slot<ServiceConnection>()
every { context.bindService(any<Intent>(), capture(slot), any<Int>()) } returns true
// Run connect in a coroutine scope (it's suspend)
runTest { client.connect(context, intent, 0) }
val latch = CountDownLatch(1)
thread {
client.waitConnect()
latch.countDown()
}
// Verify it's blocked (wait a bit)
if (latch.await(100, TimeUnit.MILLISECONDS)) {
fail("waitConnect should block until connected")
}
// Simulate connection
if (slot.isCaptured) {
slot.captured.onServiceConnected(ComponentName("pkg", "cls"), binder)
} else {
fail("ServiceConnection was not captured")
}
// Verify it unblocks
if (!latch.await(1, TimeUnit.SECONDS)) {
fail("waitConnect should unblock after connection")
}
assertNotNull(client.serviceP)
}
@Test
fun `close unbinds service`() = runTest {
val slot = slot<ServiceConnection>()
every { context.bindService(any<Intent>(), capture(slot), any<Int>()) } returns true
client.connect(context, intent, 0)
if (slot.isCaptured) {
client.close()
verify { context.unbindService(slot.captured) }
assertNull(client.serviceP)
} else {
fail("ServiceConnection was not captured")
}
}
}

View file

@ -1,11 +1,30 @@
# `:core:strings`
## Overview
The `:core:strings` module is the centralized source for all UI strings and localizable resources. It uses the **Compose Multiplatform Resource** library to provide a type-safe way to access strings.
## Key Features
- **Single Source of Truth**: All UI strings must be defined in this module, not in the `app` module or feature modules.
- **Type-Safety**: Generates a `Res` object that allows accessing strings like `Res.string.your_key` with compile-time checking.
## Usage
The library provides a standard way to access strings in Jetpack Compose.
```kotlin
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.your_string_key
Text(text = stringResource(Res.string.your_string_key))
```
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:strings[strings]:::null
:core:strings[strings]:::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

@ -14,16 +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.meshtastic.core.strings
package org.meshtastic.core.strings
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
/** Retrieves a string from the [StringResource] in a blocking manner. Use primarily in non-composable code. */
fun getString(stringResource: StringResource): String = runBlocking {
org.jetbrains.compose.resources.getString(stringResource)
}
/** Retrieves a formatted string from the [StringResource] in a blocking manner. */
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
val resolvedArgs =
formatArgs.map { arg ->

View file

@ -259,6 +259,7 @@
<string name="debug_filter_clear">Clear all filters</string>
<string name="debug_filter_add_custom">Add custom filter</string>
<string name="debug_filter_preset_title">Preset Filters</string>
<string name="debug_filter_show_ignored">Only show ignored Nodes</string>
<string name="debug_store_logs_title">Store mesh logs</string>
<string name="debug_store_logs_summary">Disable to skip writing mesh logs to disk</string>
<string name="debug_clear">Clear Logs</string>
@ -303,7 +304,6 @@
<string name="select_all">Select all</string>
<string name="close_selection">Close selection</string>
<string name="delete_selection">Delete selected</string>
<string name="map_style_selection">Style Selection</string>
<string name="map_download_region">Download Region</string>
<string name="name">Name</string>
<string name="description">Description</string>
@ -561,6 +561,7 @@
<string name="state_broadcast_seconds">State broadcast (seconds)</string>
<string name="send_bell_with_alert_message">Send bell with alert message</string>
<string name="friendly_name">Friendly name</string>
<string name="friendly_address">Friendly address</string>
<string name="gpio_pin_to_monitor">GPIO pin to monitor</string>
<string name="detection_trigger_type">Detection trigger type</string>
<string name="use_input_pullup_mode">Use INPUT_PULLUP mode</string>
@ -847,13 +848,10 @@
<string name="node_count_template">(%1$d online / %2$d shown / %3$d total)</string>
<string name="react">React</string>
<string name="disconnect">Disconnect</string>
<string name="scanning_bluetooth">Scanning for Bluetooth devices…</string>
<string name="no_ble_devices">No paired Bluetooth devices.</string>
<string name="no_network_devices">No Network devices found.</string>
<string name="no_usb_devices">No USB Serial devices found.</string>
<string name="scroll_to_bottom">Scroll to bottom</string>
<string name="meshtastic">Meshtastic</string>
<string name="scanning">Scanning</string>
<string name="security_icon_description">Security Status</string>
<string name="security_icon_secure">Secure</string>
<string name="security_icon_badge_warning_description">Warning Badge</string>
@ -908,9 +906,7 @@
<string name="pax">PAX</string>
<string name="no_pax_metrics_logs">No PAX metrics available.</string>
<string name="wifi_devices">WiFi Devices</string>
<string name="ble_devices">BLE Devices</string>
<string name="bluetooth_paired_devices">Paired devices</string>
<string name="bluetooth_available_devices">Available devices</string>
<string name="connected_device">Connected Device</string>
<string name="routing_error_rate_limit_exceeded">Rate Limit Exceeded. Please try again later.</string>
@ -923,6 +919,7 @@
<string name="firmware_edition">Firmware Edition</string>
<string name="recent_network_devices">Recent Network Devices</string>
<string name="discovered_network_devices">Discovered Network Devices</string>
<string name="bluetooth_available_devices">Available Bluetooth Devices</string>
<string name="get_started">Get started</string>
<string name="intro_welcome">Welcome to</string>
@ -1019,17 +1016,14 @@
<string name="analytics_notice">Analytics are collected to help us improve the Android app (thank you), we will receive anonymized information about user behavior. This includes crash reports, screens used in the app, etc.</string>
<string name="analytics_platforms">"Analytics platforms: "</string>
<string name="firebase_link" translatable="false">Firebase https://firebase.google.com/</string>
<string name="datadog_link" translatable="false">Datadog https://www.datadoghq.com/</string>
<string name="analytics_platforms">Analytics platforms:</string>
<string name="firebase_link" translatable="false">Firebase: https://firebase.google.com/</string>
<string name="datadog_link" translatable="false">Datadog: https://www.datadoghq.com/</string>
<string name="for_more_information_see_our_privacy_policy">For more information, see our privacy policy.</string>
<string name="privacy_url" translatable="false">" https://meshtastic.org/docs/legal/privacy/"</string>
<string name="privacy_url" translatable="false">https://meshtastic.org/docs/legal/privacy/</string>
<string name="unset">Unset - 0</string>
<string name="relayed_by">Relayed by: %1$s</string>
<plurals name="relays">
<item quantity="one">Heard %1$d Relay</item>
<item quantity="other">Heard %1$d Relays</item>
</plurals>
<string name="relays">Heard relays: %1$d</string>
<string name="firmware_update_usb_bootloader_warning">%1$s usually ships with a bootloader that does not support OTA updates. You may need to flash an OTA-capable bootloader over USB before flashing OTA.</string>
<string name="learn_more">Learn more</string>
@ -1079,7 +1073,7 @@
<string name="chirpy">Chirpy</string>
<string name="firmware_update_rebooting">Rebooting to DFU...</string>
<string name="firmware_update_waiting_for_device">Waiting for DFU device...</string>
<string name="firmware_update_copying">Copying firmware...</string>
<string name="firmware_update_copying">High-five! Wait, copying firmware...</string>
<string name="firmware_update_save_dfu_file">Please save the .uf2 file to your device&apos;s DFU drive.</string>
<string name="firmware_update_flashing">Flashing device, please wait...</string>
<string name="firmware_update_method_usb">USB File Transfer</string>
@ -1119,18 +1113,9 @@
<string name="interval_unset">Unset</string>
<string name="interval_always_on">Always On</string>
<plurals name="plurals_seconds">
<item quantity="one">1 second</item>
<item quantity="other">%1$d seconds</item>
</plurals>
<plurals name="plurals_minutes">
<item quantity="one">1 minute</item>
<item quantity="other">%1$d minutes</item>
</plurals>
<plurals name="plurals_hours">
<item quantity="one">1 hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<string name="plurals_seconds">Seconds: %1$d</string>
<string name="plurals_minutes">Minutes: %1$d</string>
<string name="plurals_hours">Hours: %1$d</string>
<!-- Compass -->
<string name="compass_title">Compass</string>
@ -1181,4 +1166,16 @@
<string name="generate_qr_code">Generate QR Code</string>
<string name="nfc_disabled">NFC is disabled. Please enable it in system settings.</string>
<string name="all_time">All</string>
<string name="bluetooth_permission">Bluetooth</string>
<string name="configure_bluetooth_permissions">Configure Bluetooth Permissions</string>
<string name="connect_to_radio">Connect to Radio</string>
<string name="connect_to_radio_description">Scan for and connect to your Meshtastic mesh radio device.</string>
<string name="bluetooth_feature_discovery">Discovery</string>
<string name="bluetooth_feature_discovery_description">Find and identify Meshtastic devices near you.</string>
<string name="bluetooth_feature_config">Configuration</string>
<string name="bluetooth_feature_config_description">Wirelessly manage your device settings and channels.</string>
<string name="permission_granted">Permission granted</string>
<string name="permission_denied">Permission denied</string>
</resources>

View file

@ -1,11 +1,65 @@
# `:core:ui`
## Overview
The `:core:ui` module contains shared Jetpack Compose components, themes, and utility functions used across the entire Meshtastic Android application. It ensures a consistent look and feel following Material 3 guidelines.
## Key Components
### 1. Alert Dialogs (`org.meshtastic.core.ui.component.AlertDialogs.kt`)
- **`MeshtasticDialog`**: The base dialog component for all alerts.
- **`MeshtasticResourceDialog`**: Optimized for dialogs with resource-only content.
- **`MeshtasticTextDialog`**: Optimized for dialogs with mixed resource and raw text content.
### 2. Common UI Elements
- **`LastHeardInfo`**: Displays when a node was last seen.
- **`TelemetryInfo`**: Displays battery, voltage, and other telemetry data.
- **`TransportIcon`**: Shows the connection type (BLE, USB, TCP).
- **`MainAppBar`**: The standard top app bar used in the app.
### 3. Preferences
Standardized Material 3 preference components for settings screens:
- `RegularPreference`
- `SwitchPreference`
- `DropDownPreference`
- `SliderPreference`
- `EditTextPreference`
### 4. Utilities
- **`ModifierExtensions.kt`**: Useful Compose Modifiers (e.g., conditional modifiers).
- **`ProtoExtensions.kt`**: Extensions for mapping Protobuf models to UI-friendly strings or icons.
## Usage
Most components are designed to be used with the **Compose Multiplatform Resource** library for strings.
```kotlin
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.ok
MeshtasticResourceDialog(
title = Res.string.your_title,
message = Res.string.your_message,
onDismissRequest = { /* ... */ },
confirmButtonText = Res.string.ok
)
```
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:ui[ui]:::null
:core:ui[ui]:::android-library
:core:ui -.-> :core:common
:core:ui -.-> :core:barcode
:core:ui -.-> :core:nfc
:core:ui -.-> :core:data
:core:ui -.-> :core:database
:core:ui -.-> :core:model
:core:ui -.-> :core:prefs
:core:ui -.-> :core:proto
:core:ui -.-> :core:service
:core:ui -.-> :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;

View file

@ -26,6 +26,7 @@ plugins {
configure<LibraryExtension> { namespace = "org.meshtastic.core.ui" }
dependencies {
implementation(projects.core.common)
implementation(projects.core.barcode)
implementation(projects.core.nfc)
implementation(projects.core.data)
@ -46,6 +47,7 @@ dependencies {
implementation(libs.guava)
implementation(libs.zxing.core)
implementation(libs.kermit)
implementation(libs.nordic.common.core)
debugImplementation(libs.androidx.compose.ui.test.manifest)

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 org.meshtastic.core.ui.component
import android.text.Spannable
@ -32,6 +31,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview
@ -48,9 +48,15 @@ fun AutoLinkText(
style: TextStyle = TextStyle.Default,
linkStyles: TextLinkStyles = DefaultTextLinkStyles,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
) {
val spannable = remember(text) { linkify(text) }
Text(text = spannable.toAnnotatedString(linkStyles), modifier = modifier, style = style.copy(color = color))
Text(
text = spannable.toAnnotatedString(linkStyles),
modifier = modifier,
style = style.copy(color = color),
textAlign = textAlign,
)
}
private fun linkify(text: String) = Factory.getInstance().newSpannable(text).also {

View file

@ -24,7 +24,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.node_sort_last_heard
import org.meshtastic.core.ui.R

View file

@ -16,8 +16,6 @@
*/
package org.meshtastic.core.ui.component
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.compose.runtime.Composable
@ -25,45 +23,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LifecycleResumeEffect
import org.meshtastic.core.model.util.nowMillis
import no.nordicsemi.android.common.core.registerReceiver
/**
* Remembers a time tick that updates every minute. Uses [registerReceiver] from Nordic Common for automatic lifecycle
* management.
*
* @return The current time in milliseconds, updating every minute.
*/
@Composable
fun rememberTimeTickWithLifecycle(): Long {
val context = LocalContext.current
var value by remember { mutableLongStateOf(nowMillis) }
val receiver = TimeBroadcastReceiver { value = nowMillis }
var value by remember { mutableLongStateOf(System.currentTimeMillis()) }
LifecycleResumeEffect(Unit) {
receiver.register(context)
value = nowMillis
onPauseOrDispose { receiver.unregister(context) }
}
registerReceiver(IntentFilter(Intent.ACTION_TIME_TICK)) { value = System.currentTimeMillis() }
return value
}
private class TimeBroadcastReceiver(val onTimeChanged: () -> Unit) : BroadcastReceiver() {
private var registered = false
override fun onReceive(context: Context, intent: Intent) {
onTimeChanged()
}
fun register(context: Context) {
if (!registered) {
val filter = IntentFilter(Intent.ACTION_TIME_TICK)
context.registerReceiver(this, filter)
registered = true
}
}
fun unregister(context: Context) {
if (registered) {
context.unregisterReceiver(this)
registered = false
}
}
}

View file

@ -17,9 +17,9 @@
package org.meshtastic.core.ui.util
import android.text.format.DateUtils
import com.meshtastic.core.strings.getString
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.now
import org.meshtastic.core.strings.unknown
import kotlin.time.Duration.Companion.milliseconds

View file

@ -14,18 +14,13 @@
* 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.ui.util
import org.meshtastic.core.model.util.toPosixString
import java.time.ZoneId
import androidx.compose.ui.Modifier
/**
* Generates a POSIX time zone string from a [ZoneId].
*
* @deprecated Use [org.meshtastic.core.model.util.toPosixString] instead.
* Conditionally applies the [action] to the receiver [Modifier] if [precondition] is true. Otherwise, returns the
* receiver unchanged.
*/
@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()
inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier =
if (precondition) action() else this

View file

@ -20,7 +20,7 @@ import android.text.format.DateUtils
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.unknown_age
import org.meshtastic.proto.Channel