mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
7a68802bc2
commit
6bfa5b5f70
214 changed files with 3471 additions and 2405 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/#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
50
core/barcode/README.md
Normal 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
86
core/ble/README.md
Normal 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
51
core/ble/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
135
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleError.kt
Normal file
135
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleError.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
58
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt
Normal file
58
core/ble/src/main/kotlin/org/meshtastic/core/ble/BleRetry.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}})"
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ configure<LibraryExtension> {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.di)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.proto)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
```
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ afterEvaluate {
|
|||
|
||||
dependencies {
|
||||
api(projects.core.proto)
|
||||
api(projects.core.common)
|
||||
|
||||
api(libs.androidx.annotation)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
29
core/nfc/README.md
Normal 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-->
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue