refactor: remove AIDL API and modernize radio architecture

Remove the deprecated AIDL/IPC API surface and perform deep architectural
modernization of the radio command pipeline, aligning with the meshtastic-sdk
AdminApiImpl pattern for future SDK migration.

Key changes:

1. AIDL Removal & Infrastructure Cleanup
   - Delete core:api module and all AIDL interfaces
   - Remove ServiceBroadcasts + CommonParcelable infrastructure
   - Remove core:api from CI workflow lint/publish steps

2. Model Modernization
   - Introduce NodeAddress sealed class with type-safe addressing
   - Remove deprecated DataPacket constants in favor of NodeAddress
   - Consolidate dual node maps into single source with getNodeById
   - Split large model files, deduplicate NodeEntity, flatten RadioController

3. Service Layer Refactoring (SDK-aligned)
   - Remove ServiceAction sealed class, use direct suspend calls
   - Convert CommandSender & MeshActionHandler to suspend APIs
   - Merge MeshActionHandler into DirectRadioControllerImpl
     (ViewModel → RadioController → CommandSender, no intermediate layer)
   - Build AdminMessage protos directly with typed protos end-to-end
   - Apply structured concurrency to NodeRequestActions/NodeManagementActions
   - Fix CancellationException handling throughout

Architecture (before → after):
  ViewModel → RadioController → Handler → CommandSender → PacketHandler
  ViewModel → RadioController → CommandSender → PacketHandler

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-23 13:45:11 -05:00
parent e167b58615
commit 4f57e65097
179 changed files with 1739 additions and 5492 deletions

View File

@@ -87,7 +87,6 @@ Each module has its own README with details on its responsibilities, API surface
| Module | Description |
|---|---|
| [core/api](core/api/README.md) | AIDL service API for third-party integrations |
| [core/domain](core/domain/README.md) | Business-logic use cases (radio config, sessions, exports) |
| [core/repository](core/repository/README.md) | Data & infrastructure contracts (RadioTransport, NodeRepository, ServiceRepository) |
| [core/takserver](core/takserver/README.md) | Meshtastic ↔ TAK (ATAK/iTAK) bridge — CoT server & conversion |
@@ -123,13 +122,9 @@ Each module has its own README with details on its responsibilities, API surface
You can help translate the app into your native language using [Crowdin](https://crowdin.meshtastic.org/android).
## API & Integration
## Integration
Developers can integrate with the Meshtastic Android app using our published API library via **JitPack**. This allows third-party applications (like the ATAK plugin) to communicate with the mesh service via AIDL.
For detailed integration instructions, see [core/api/README.md](core/api/README.md).
Additionally, the app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh.
The app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh.
## Building the Android App
> [!WARNING]

View File

@@ -87,6 +87,7 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
@@ -433,7 +434,7 @@ fun MapView(
}
}
fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) {
fun getUsername(id: String?) = if (id == NodeAddress.ID_LOCAL || (myId != null && id == myId)) {
getString(Res.string.you)
} else {
mapViewModel.getUser(id).long_name

View File

@@ -50,6 +50,7 @@ import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@@ -670,8 +671,7 @@ class MapViewModel(
(currentTileProvider as? MBTilesProvider)?.close()
}
override fun getUser(userId: String?) =
nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
override fun getUser(userId: String?) = nodeRepository.getUser(userId ?: NodeAddress.ID_BROADCAST)
}
enum class LayerType {

View File

@@ -161,16 +161,12 @@
android:name="google_analytics_default_allow_analytics_storage"
android:value="false" />
<!-- This is the public API for doing mesh radio operations from android apps -->
<!-- In-app mesh foreground service -->
<service
android:name="org.meshtastic.core.service.MeshService"
android:enabled="true"
android:foregroundServiceType="connectedDevice|location"
android:exported="true" tools:ignore="ExportedActivity">
<intent-filter>
<action android:name="com.geeksville.mesh.Service" />
</intent-filter>
</service>
android:exported="false" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
@@ -272,7 +268,7 @@
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<!-- for testing -->
<action android:name="com.geeksville.mesh.SIM_BOOT" />
<action android:name="org.meshtastic.app.SIM_BOOT" />
</intent-filter>
<!-- Also restart our service if the app gets upgraded -->

View File

@@ -63,7 +63,8 @@ import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.service.MeshServiceClient
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.service.startService
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
@@ -95,18 +96,9 @@ class MainActivity : AppCompatActivity() {
private val usbRepository: UsbRepository by inject()
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
*/
internal val meshServiceClient: MeshServiceClient by inject { parametersOf(this) }
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
// Eagerly evaluate lazy Koin dependency so it registers its LifecycleObserver
meshServiceClient.hashCode()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -168,6 +160,11 @@ class MainActivity : AppCompatActivity() {
handleIntent(intent)
}
override fun onStart() {
super.onStart()
MeshService.startService(this)
}
override fun onResume() {
super.onResume()
// Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is

View File

@@ -19,7 +19,7 @@ package org.meshtastic.app.service
import dev.mokkery.MockMode
import dev.mokkery.mock
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
@@ -28,7 +28,7 @@ class Fakes {
val service: RadioInterfaceService = mock(MockMode.autofill)
}
class FakeMeshServiceNotifications : MeshServiceNotifications {
class FakeMeshNotificationManager : MeshNotificationManager {
override fun clearNotifications() {}
override fun initChannels() {}

View File

@@ -81,7 +81,6 @@ private val DEVICE_TEST_MODULES = listOf(":core:database", ":core:model")
private val ALL_MODULES_FULL =
listOf(
":androidApp",
":core:api",
":core:barcode",
":core:ble",
":core:common",
@@ -115,7 +114,7 @@ private val ALL_MODULES_FULL =
)
/** Android-only modules that don't apply the KMP plugin. */
private val ANDROID_ONLY_MODULES = setOf(":androidApp", ":core:api", ":core:barcode", ":feature:widget")
private val ANDROID_ONLY_MODULES = setOf(":androidApp", ":core:barcode", ":feature:widget")
/**
* Modules excluded from Dokka aggregation. :core:proto contains only auto-generated Wire classes (no KDoc value) and
@@ -128,6 +127,6 @@ private fun allModules(): List<String> = ALL_MODULES_FULL
/**
* Modules that apply the KMP plugin and should be compiled for JVM + iOS targets. Excludes pure-Android modules
* (:androidApp, :core:api, :core:barcode, :feature:widget) and the desktop JVM-only module.
* (:androidApp, :core:barcode, :feature:widget) and the desktop JVM-only module.
*/
private fun kmpModules(): List<String> = allModules().filter { it !in ANDROID_ONLY_MODULES + ":desktopApp" }

View File

@@ -61,8 +61,6 @@ component_management:
ignore:
- "**/build/**"
- "**/*.pb.kt" # Generated Protobuf code
- "**/*.aidl" # AIDL interface files
- "**/aidl/**" # Generated AIDL code
- "core/resources/**" # Centralized resources
- "**/test/**" # Unit tests
- "**/androidTest/**" # Instrumented tests

View File

@@ -1,79 +0,0 @@
# `:core:api` (Meshtastic Android API)
> **Deprecation notice**
>
> The AIDL-based service integration (`IMeshService`) is deprecated and will be removed in a future
> release. The recommended integration path for ATAK and other external apps is the built-in
> **Local TAK Server** introduced in `core:takserver`. Connect ATAK to `127.0.0.1:8087` (TCP) and
> import the DataPackage exported from the TAK Config screen to complete setup. No AIDL binding or
> JitPack dependency is required.
## Overview
The `:core:api` module contains the AIDL interface and dependencies for third-party applications
that currently integrate with the Meshtastic Android app via service binding. New integrations
should use the Local TAK Server instead (see deprecation notice above).
## Integration
To communicate with the Meshtastic Android service from your own application, we recommend using **JitPack**.
### Dependencies
Add the following to your `build.gradle.kts`:
```kotlin
dependencies {
// The core AIDL interface and Intent constants
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-api:v2.x.x")
// Data models (DataPacket, MeshUser, NodeInfo, etc.) - Kotlin Multiplatform
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-model:v2.x.x")
// Protobuf definitions (PortNum, Telemetry, etc.) - Kotlin Multiplatform
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.
```kotlin
val intent = Intent("com.geeksville.mesh.Service")
// ... query package manager and bind
```
### 2. Interact with the API
Once bound, cast the `IBinder` to `IMeshService`.
### 3. Register a BroadcastReceiver
Use `MeshtasticIntent` constants for actions. Remember to use `RECEIVER_EXPORTED` on Android 13+.
## Key Components
- **`IMeshService.aidl`**: The primary AIDL interface.
- **`MeshtasticIntent.kt`**: Defines Intent actions for received messages and status changes.
## Module dependency graph
<!--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 compose-desktop-application 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-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<!--endregion-->

View File

@@ -1,52 +0,0 @@
/*
* 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/>.
*/
plugins {
alias(libs.plugins.meshtastic.android.library)
id("meshtastic.publishing")
}
configure<com.android.build.api.dsl.LibraryExtension> {
namespace = "org.meshtastic.core.api"
buildFeatures { aidl = true }
defaultConfig {
// Lowering minSdk to 21 for better compatibility with ATAK and other plugins
minSdk = 21
}
publishing { singleVariant("release") { withSourcesJar() } }
}
// Suppress dep-ann warnings from AIDL-generated code where Javadoc @deprecated
// doesn't produce @Deprecated annotations on Stub/Proxy override methods.
tasks.withType<JavaCompile>().configureEach { options.compilerArgs.add("-Xlint:-dep-ann") }
// Map the Android component to a Maven publication.
// afterEvaluate is required because AGP registers the "release" component lazily
// after the android.publishing.singleVariant("release") configuration runs.
afterEvaluate {
publishing {
publications {
register<MavenPublication>("release") {
from(components["release"])
artifactId = "meshtastic-android-api"
}
}
}
}
dependencies { api(projects.core.model) }

View File

@@ -1 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable DataPacket;

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable MeshUser;

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable MyNodeInfo;

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable NodeInfo;

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable Position;

View File

@@ -1,207 +0,0 @@
package org.meshtastic.core.service;
// Declare any non-default types here with import statements
import org.meshtastic.core.model.DataPacket;
import org.meshtastic.core.model.NodeInfo;
import org.meshtastic.core.model.MeshUser;
import org.meshtastic.core.model.Position;
import org.meshtastic.core.model.MyNodeInfo;
/**
This is the public android API for talking to meshtastic radios.
@deprecated The AIDL service integration is deprecated and will be removed in a future release.
New integrations should connect via the built-in Local TAK Server on 127.0.0.1:8087 (TCP).
Import the DataPackage from the TAK Config screen in the Meshtastic app to configure ATAK.
To connect to meshtastic you should bind to it per https://developer.android.com/guide/components/bound-services
The intent you use to reach the service should ideally use the action string:
val intent = Intent("com.geeksville.mesh.Service")
Or if using an explicit intent:
val intent = Intent().apply {
setClassName(
"com.geeksville.mesh",
"org.meshtastic.core.service.MeshService"
)
}
In Android 11+ you *may* need to add the following to the client app's manifest to allow binding of the mesh service:
<queries>
<package android:name="com.geeksville.mesh" />
</queries>
For additional information, see https://developer.android.com/guide/topics/manifest/queries-element
Once you have bound to the service you should register your broadcast receivers per https://developer.android.com/guide/components/broadcasts#context-registered-receivers
// com.geeksville.mesh.x broadcast intents, where x is:
// RECEIVED.<portnumm> - will **only** deliver packets for the specified port number. If a wellknown portnums.proto name for portnum is known it will be used
// (i.e. com.geeksville.mesh.RECEIVED.TEXT_MESSAGE_APP) else the numeric portnum will be included as a base 10 integer (com.geeksville.mesh.RECEIVED.4403 etc...)
// NODE_CHANGE for new IDs appearing or disappearing
// CONNECTION_CHANGED for losing/gaining connection to the packet radio
// MESSAGE_STATUS_CHANGED for any message status changes (for sent messages only, payload will contain a message ID and a MessageStatus)
Note - these calls might throw RemoteException to indicate mesh error states
*/
interface IMeshService {
/// Tell the service where to send its broadcasts of received packets
/// This call is only required for manifest declared receivers. If your receiver is context-registered
/// you don't need this.
void subscribeReceiver(String packageName, String receiverName);
/**
* Set the user info for this node
*/
void setOwner(in MeshUser user);
void setRemoteOwner(in int requestId, in int destNum, in byte []payload);
void getRemoteOwner(in int requestId, in int destNum);
/// Return my unique user ID string
String getMyId();
/// Return a unique packet ID
int getPacketId();
/*
Send a packet to a specified node name
typ is defined in mesh.proto Data.Type. For now juse use 0 to mean opaque bytes.
destId can be null to indicate "broadcast message"
messageStatus and id of the provided message will be updated by this routine to indicate
message send status and the ID that can be used to locate the message in the future
*/
void send(inout DataPacket packet);
/**
Get the IDs of everyone on the mesh. You should also subscribe for NODE_CHANGE broadcasts.
*/
List<NodeInfo> getNodes();
/// This method is only intended for use in our GUI, so the user can set radio options
/// It returns a DeviceConfig protobuf.
byte []getConfig();
/// It sets a Config protobuf via admin packet
void setConfig(in byte []payload);
/// Set and get a Config protobuf via admin packet
void setRemoteConfig(in int requestId, in int destNum, in byte []payload);
void getRemoteConfig(in int requestId, in int destNum, in int configTypeValue);
/// Set and get a ModuleConfig protobuf via admin packet
void setModuleConfig(in int requestId, in int destNum, in byte []payload);
void getModuleConfig(in int requestId, in int destNum, in int moduleConfigTypeValue);
/// Set and get the Ext Notification Ringtone string via admin packet
void setRingtone(in int destNum, in String ringtone);
void getRingtone(in int requestId, in int destNum);
/// Set and get the Canned Message Messages string via admin packet
void setCannedMessages(in int destNum, in String messages);
void getCannedMessages(in int requestId, in int destNum);
/// This method is only intended for use in our GUI, so the user can set radio options
/// It sets a Channel protobuf via admin packet
void setChannel(in byte []payload);
/// Set and get a Channel protobuf via admin packet
void setRemoteChannel(in int requestId, in int destNum, in byte []payload);
void getRemoteChannel(in int requestId, in int destNum, in int channelIndex);
/// Send beginEditSettings admin packet to nodeNum
void beginEditSettings(in int destNum);
/// Send commitEditSettings admin packet to nodeNum
void commitEditSettings(in int destNum);
/// delete a specific nodeNum from nodeDB
void removeByNodenum(in int requestID, in int nodeNum);
/// Send position packet with wantResponse to nodeNum
void requestPosition(in int destNum, in Position position);
/// Send setFixedPosition admin packet (or removeFixedPosition if Position is empty)
void setFixedPosition(in int destNum, in Position position);
/// Send traceroute packet with wantResponse to nodeNum
void requestTraceroute(in int requestId, in int destNum);
/// Send neighbor info packet with wantResponse to nodeNum
void requestNeighborInfo(in int requestId, in int destNum);
/// Send Shutdown admin packet to nodeNum
void requestShutdown(in int requestId, in int destNum);
/// Send Reboot admin packet to nodeNum
void requestReboot(in int requestId, in int destNum);
/// Send FactoryReset admin packet to nodeNum
void requestFactoryReset(in int requestId, in int destNum);
/// Send reboot to DFU admin packet
void rebootToDfu(in int destNum);
/// Send NodedbReset admin packet to nodeNum
void requestNodedbReset(in int requestId, in int destNum, in boolean preserveFavorites);
/// Returns a ChannelSet protobuf
byte []getChannelSet();
/**
Is the packet radio currently connected to the phone? Returns a ConnectionState string.
*/
String connectionState();
/**
* @deprecated For internal use only. External callers must not invoke this method;
* it will be removed from the public API in a future release.
*/
boolean setDeviceAddress(String deviceAddr);
/// Get basic device hardware info about our connected radio. Will never return NULL. Will return NULL
/// if no my node info is available (i.e. it will not throw an exception)
MyNodeInfo getMyNodeInfo();
/**
* @deprecated No-op stub — firmware update is now handled entirely by the in-app OTA system.
* This method will be removed from the public API in a future release.
*/
void startFirmwareUpdate();
/**
* @deprecated Always returns {@code -4}, which is outside the documented range.
* Firmware update progress is now tracked internally by the in-app OTA system.
* This method will be removed from the public API in a future release.
*/
int getUpdateStatus();
/// Start providing location (from phone GPS) to mesh
void startProvideLocation();
/// Stop providing location (from phone GPS) to mesh
void stopProvideLocation();
/// Send request for node UserInfo
void requestUserInfo(in int destNum);
/// Request device connection status from the radio
void getDeviceConnectionStatus(in int requestId, in int destNum);
/// Send request for telemetry to nodeNum
void requestTelemetry(in int requestId, in int destNum, in int type);
/**
* Tell the node to reboot into OTA mode for firmware update via BLE or WiFi (ESP32 only)
* mode is 1 for BLE, 2 for WiFi
* hash is the 32-byte firmware SHA256 hash (optional, can be null)
*/
void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash);
}

View File

@@ -1,85 +0,0 @@
/*
* 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.api
import org.meshtastic.core.api.MeshtasticIntent.EXTRA_CONNECTED
import org.meshtastic.core.api.MeshtasticIntent.EXTRA_NODEINFO
import org.meshtastic.core.api.MeshtasticIntent.EXTRA_PACKET_ID
import org.meshtastic.core.api.MeshtasticIntent.EXTRA_STATUS
/**
* Constants for Meshtastic Android Intents. These are used by external applications to communicate with the Meshtastic
* service.
*/
object MeshtasticIntent {
private const val PREFIX = "com.geeksville.mesh"
/** Broadcast when a node's information changes. Extra: [EXTRA_NODEINFO] */
const val ACTION_NODE_CHANGE = "$PREFIX.NODE_CHANGE"
/** Broadcast when the mesh radio connects. Extra: [EXTRA_CONNECTED] */
const val ACTION_MESH_CONNECTED = "$PREFIX.MESH_CONNECTED"
/** Broadcast when the mesh radio disconnects. */
const val ACTION_MESH_DISCONNECTED = "$PREFIX.MESH_DISCONNECTED"
/**
* Legacy broadcast for connection changes. Extra: [EXTRA_CONNECTED]
*
* Prefer [ACTION_MESH_CONNECTED] / [ACTION_MESH_DISCONNECTED] instead. This constant will be removed from the
* public API in a future release.
*/
@Deprecated(
message = "Use ACTION_MESH_CONNECTED / ACTION_MESH_DISCONNECTED instead.",
replaceWith = ReplaceWith("ACTION_MESH_CONNECTED"),
)
const val ACTION_CONNECTION_CHANGED = "$PREFIX.CONNECTION_CHANGED"
/** Broadcast for message status updates. Extras: [EXTRA_PACKET_ID], [EXTRA_STATUS] */
const val ACTION_MESSAGE_STATUS = "$PREFIX.MESSAGE_STATUS"
/** Received a text message. */
const val ACTION_RECEIVED_TEXT_MESSAGE_APP = "$PREFIX.RECEIVED.TEXT_MESSAGE_APP"
/** Received a position update. */
const val ACTION_RECEIVED_POSITION_APP = "$PREFIX.RECEIVED.POSITION_APP"
/** Received node info. */
const val ACTION_RECEIVED_NODEINFO_APP = "$PREFIX.RECEIVED.NODEINFO_APP"
/** Received telemetry data. */
const val ACTION_RECEIVED_TELEMETRY_APP = "$PREFIX.RECEIVED.TELEMETRY_APP"
/** Received ATAK Plugin data. */
const val ACTION_RECEIVED_ATAK_PLUGIN = "$PREFIX.RECEIVED.ATAK_PLUGIN"
/** Received ATAK Forwarder data. */
const val ACTION_RECEIVED_ATAK_FORWARDER = "$PREFIX.RECEIVED.ATAK_FORWARDER"
/** Received detection sensor data. */
const val ACTION_RECEIVED_DETECTION_SENSOR_APP = "$PREFIX.RECEIVED.DETECTION_SENSOR_APP"
/** Received private app data. */
const val ACTION_RECEIVED_PRIVATE_APP = "$PREFIX.RECEIVED.PRIVATE_APP"
// standard EXTRA bundle definitions
const val EXTRA_CONNECTED = "$PREFIX.Connected"
const val EXTRA_PAYLOAD = "$PREFIX.Payload"
const val EXTRA_NODEINFO = "$PREFIX.NodeInfo"
const val EXTRA_PACKET_ID = "$PREFIX.PacketId"
const val EXTRA_STATUS = "$PREFIX.Status"
}

View File

@@ -17,7 +17,6 @@
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.kotlin.parcelize)
id("meshtastic.kmp.jvm.android")
id("meshtastic.koin")
}

View File

@@ -20,32 +20,14 @@ 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,

View File

@@ -1,34 +0,0 @@
/*
* 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.common.util
import android.os.RemoteException
import co.touchlab.kermit.Logger
/**
* Wraps an operation and converts any thrown exceptions into [RemoteException] for safe return through an AIDL
* interface.
*/
fun <T> toRemoteExceptions(inner: () -> T): T = try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Uncaught exception in service call, returning RemoteException to client" }
when (ex) {
is RemoteException -> throw ex
else -> throw RemoteException(ex.message).apply { initCause(ex) }
}
}

View File

@@ -1,31 +0,0 @@
/*
* 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.common.util
import android.os.Parcelable
actual typealias CommonParcelable = Parcelable
actual typealias CommonParcelize = kotlinx.parcelize.Parcelize
actual typealias CommonIgnoredOnParcel = kotlinx.parcelize.IgnoredOnParcel
actual typealias CommonParceler<T> = kotlinx.parcelize.Parceler<T>
actual typealias CommonTypeParceler<T, P> = kotlinx.parcelize.TypeParceler<T, P>
actual typealias CommonParcel = android.os.Parcel

View File

@@ -1,58 +0,0 @@
/*
* 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.common.util
/** Platform-agnostic Parcelable interface. */
expect interface CommonParcelable
/** Platform-agnostic Parcelize annotation. */
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
expect annotation class CommonParcelize()
/** Platform-agnostic IgnoredOnParcel annotation. */
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
expect annotation class CommonIgnoredOnParcel()
/** Platform-agnostic Parceler interface. */
expect interface CommonParceler<T> {
fun create(parcel: CommonParcel): T
fun T.write(parcel: CommonParcel, flags: Int)
}
/** Platform-agnostic TypeParceler annotation. */
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
expect annotation class CommonTypeParceler<T, P : CommonParceler<in T>>()
/** Platform-agnostic Parcel representation for manual parceling (e.g. AIDL support). */
expect class CommonParcel {
fun readString(): String?
fun readInt(): Int
fun readLong(): Long
fun readFloat(): Float
fun createByteArray(): ByteArray?
fun writeByteArray(b: ByteArray?)
}

View File

@@ -45,38 +45,3 @@ actual fun currentLocaleCode(): String = "en"
actual fun currentLocaleQualifier(): String = "en"
actual fun String?.isValidAddress(): Boolean = false
actual interface CommonParcelable
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
actual annotation class CommonParcelize actual constructor()
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
actual annotation class CommonIgnoredOnParcel actual constructor()
actual interface CommonParceler<T> {
actual fun create(parcel: CommonParcel): T
actual fun T.write(parcel: CommonParcel, flags: Int)
}
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
actual annotation class CommonTypeParceler<T, P : CommonParceler<in T>> actual constructor()
actual class CommonParcel {
actual fun readString(): String? = null
actual fun readInt(): Int = 0
actual fun readLong(): Long = 0L
actual fun readFloat(): Float = 0.0f
actual fun createByteArray(): ByteArray? = null
actual fun writeByteArray(b: ByteArray?) {}
}

View File

@@ -1,55 +0,0 @@
/*
* 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.common.util
actual interface CommonParcelable
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
actual annotation class CommonParcelize
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
actual annotation class CommonIgnoredOnParcel
actual interface CommonParceler<T> {
actual fun create(parcel: CommonParcel): T
actual fun T.write(parcel: CommonParcel, flags: Int)
}
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
actual annotation class CommonTypeParceler<T, P : CommonParceler<in T>>
actual class CommonParcel {
actual fun readString(): String? = unsupportedParcelOperation()
actual fun readInt(): Int = unsupportedParcelOperation()
actual fun readLong(): Long = unsupportedParcelOperation()
actual fun readFloat(): Float = unsupportedParcelOperation()
actual fun createByteArray(): ByteArray? = unsupportedParcelOperation()
actual fun writeByteArray(b: ByteArray?) = unsupportedParcelOperation<Unit>()
}
private fun <T> unsupportedParcelOperation(): T =
error("CommonParcel is unavailable on JVM smoke targets. Manual parcel operations remain Android-only.")

View File

@@ -29,6 +29,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
@@ -99,7 +100,7 @@ class CommandSenderImpl(
/**
* Resolves the correct channel index for sending a packet to [toNum].
*
* PKI encryption ([DataPacket.PKC_CHANNEL_INDEX]) is only used for **admin** packets, where end-to-end encryption
* PKI encryption ([NodeAddress.PKC_CHANNEL_INDEX]) is only used for **admin** packets, where end-to-end encryption
* is appropriate. Protocol-level requests (traceroute, telemetry, position, nodeinfo, neighborinfo) must NOT use
* PKI because relay nodes need to read and/or modify the inner payload (e.g. traceroute appends each hop's node
* number). These requests fall back to the node's heard-on channel.
@@ -112,7 +113,7 @@ class CommandSenderImpl(
return when {
myNum == toNum -> 0
myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX
myNode?.hasPKC == true && destNode?.hasPKC == true -> NodeAddress.PKC_CHANNEL_INDEX
else ->
channelSet.value.settings
@@ -127,7 +128,7 @@ class CommandSenderImpl(
*/
private fun getChannelIndex(toNum: Int): Int = nodeManager.nodeDBbyNodeNum[toNum]?.channel ?: 0
override fun sendData(p: DataPacket) {
override suspend fun sendData(p: DataPacket) {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes ?: ByteString.EMPTY
require(p.dataType != 0) { "Port numbers must be non-zero!" }
@@ -152,10 +153,10 @@ class CommandSenderImpl(
sendNow(p)
}
private fun sendNow(p: DataPacket) {
private suspend fun sendNow(p: DataPacket) {
val meshPacket =
buildMeshPacket(
to = resolveNodeNum(p.to ?: DataPacket.ID_BROADCAST),
to = resolveNodeNum(NodeAddress.fromString(p.to)),
id = p.id,
wantAck = p.wantAck,
hopLimit = if (p.hopLimit > 0) p.hopLimit else computeHopLimit(),
@@ -172,7 +173,7 @@ class CommandSenderImpl(
packetHandler.sendToRadio(meshPacket)
}
override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
override suspend fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
@@ -191,7 +192,7 @@ class CommandSenderImpl(
return packetHandler.sendToRadioAndAwait(packet)
}
override fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) {
override suspend fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) {
val myNum = nodeManager.myNodeNum.value ?: return
val idNum = destNum ?: myNum
Logger.d { "Sending our position/time to=$idNum $pos" }
@@ -215,7 +216,7 @@ class CommandSenderImpl(
)
}
override fun requestPosition(destNum: Int, currentPosition: Position) {
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
val meshPosition =
ProtoPosition(
latitude_i = Position.degI(currentPosition.latitude),
@@ -238,7 +239,7 @@ class CommandSenderImpl(
)
}
override fun setFixedPosition(destNum: Int, pos: Position) {
override suspend fun setFixedPosition(destNum: Int, pos: Position) {
val meshPos =
ProtoPosition(
latitude_i = Position.degI(pos.latitude),
@@ -255,7 +256,7 @@ class CommandSenderImpl(
nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum.value ?: 0, meshPos, nowMillis)
}
override fun requestUserInfo(destNum: Int) {
override suspend fun requestUserInfo(destNum: Int) {
val myNum = nodeManager.myNodeNum.value ?: return
val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
packetHandler.sendToRadio(
@@ -272,7 +273,7 @@ class CommandSenderImpl(
)
}
override fun requestTraceroute(requestId: Int, destNum: Int) {
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteHandler.recordStartTime(requestId)
packetHandler.sendToRadio(
buildMeshPacket(
@@ -285,7 +286,7 @@ class CommandSenderImpl(
)
}
override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
val portNum: PortNum
@@ -319,7 +320,7 @@ class CommandSenderImpl(
)
}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoHandler.recordStartTime(requestId)
val myNum = nodeManager.myNodeNum.value ?: 0
if (destNum == myNum) {
@@ -373,20 +374,16 @@ class CommandSenderImpl(
}
}
fun resolveNodeNum(toId: String): Int = when (toId) {
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
fun resolveNodeNum(address: NodeAddress): Int = when (address) {
NodeAddress.Broadcast -> NodeAddress.NODENUM_BROADCAST
else -> {
val numericNum =
if (toId.startsWith(NODE_ID_PREFIX)) {
toId.substring(NODE_ID_START_INDEX).toLongOrNull(HEX_RADIX)?.toInt()
} else {
null
}
numericNum
?: nodeManager.nodeDBbyID[toId]?.num
?: throw IllegalArgumentException("Unknown node ID $toId")
}
NodeAddress.Local -> nodeManager.myNodeNum.value ?: 0
is NodeAddress.ByNum -> address.num
is NodeAddress.ById ->
nodeManager.getNodeById(address.id)?.num
?: throw IllegalArgumentException("Unknown node ID ${address.id}")
}
private fun buildMeshPacket(
@@ -404,7 +401,7 @@ class CommandSenderImpl(
var publicKey: ByteString = ByteString.EMPTY
var actualChannel = channel
if (channel == DataPacket.PKC_CHANNEL_INDEX) {
if (channel == NodeAddress.PKC_CHANNEL_INDEX) {
pkiEncrypted = true
val destNode = nodeManager.nodeDBbyNodeNum[to]
// Resolve the public key using the same fallback as Node.hasPKC:
@@ -457,9 +454,6 @@ class CommandSenderImpl(
private const val PACKET_ID_SHIFT_BITS = 32
private const val ADMIN_CHANNEL_NAME = "admin"
private const val NODE_ID_PREFIX = "!"
private const val NODE_ID_START_INDEX = 1
private const val HEX_RADIX = 16
private const val DEFAULT_HOP_LIMIT = 3
}

View File

@@ -68,7 +68,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
private fun activeDeviceAddress(): String? =
meshPrefs.deviceAddress.value?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
override fun requestHistoryReplay(
override suspend fun requestHistoryReplay(
trigger: String,
myNodeNum: Int?,
storeForwardConfig: ModuleConfig.StoreForwardConfig?,

View File

@@ -1,404 +0,0 @@
/*
* 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.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.OTAMode
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Single
class MeshActionHandlerImpl(
private val nodeManager: NodeManager,
private val commandSender: CommandSender,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val dataHandler: Lazy<MeshDataHandler>,
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
private val uiPrefs: UiPrefs,
private val databaseManager: DatabaseManager,
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy<MeshMessageProcessor>,
private val radioConfigRepository: RadioConfigRepository,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshActionHandler {
companion object {
private const val DEFAULT_REBOOT_DELAY = 5
private const val EMOJI_INDICATOR = 1
}
override suspend fun onServiceAction(action: ServiceAction) {
Logger.d { "ServiceAction dispatched: ${action::class.simpleName}" }
ignoreExceptionSuspend {
val myNodeNum = nodeManager.myNodeNum.value
if (myNodeNum == null) {
Logger.w { "MeshActionHandlerImpl: myNodeNum is null, skipping ServiceAction!" }
if (action is ServiceAction.SendContact) {
action.result.complete(false)
}
return@ignoreExceptionSuspend
}
when (action) {
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)
is ServiceAction.Mute -> handleMute(action, myNodeNum)
is ServiceAction.Reaction -> handleReaction(action, myNodeNum)
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
val accepted =
safeCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)
action.result.complete(accepted)
}
is ServiceAction.GetDeviceMetadata -> {
commandSender.sendAdmin(action.destNum, wantResponse = true) {
AdminMessage(get_device_metadata_request = true)
}
}
}
}
}
private fun handleFavorite(action: ServiceAction.Favorite, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) {
if (node.isFavorite) {
AdminMessage(remove_favorite_node = node.num)
} else {
AdminMessage(set_favorite_node = node.num)
}
}
nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) }
}
private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) {
val node = action.node
val newIgnoredStatus = !node.isIgnored
commandSender.sendAdmin(myNodeNum) {
if (newIgnoredStatus) {
AdminMessage(set_ignored_node = node.num)
} else {
AdminMessage(remove_ignored_node = node.num)
}
}
nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnoredStatus) }
scope.handledLaunch { packetRepository.value.updateFilteredBySender(node.user.id, newIgnoredStatus) }
}
private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) }
nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) }
}
private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) {
val channel = action.contactKey[0].digitToInt()
val destId = action.contactKey.substring(1)
val dataPacket =
DataPacket(
to = destId,
dataType = PortNum.TEXT_MESSAGE_APP.value,
bytes = action.emoji.encodeToByteArray().toByteString(),
channel = channel,
replyId = action.replyId,
wantAck = true,
emoji = EMOJI_INDICATOR,
)
.apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL }
commandSender.sendData(dataPacket)
rememberReaction(action, dataPacket.id, myNodeNum)
}
private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) {
val verifiedContact = action.contact.copy(manually_verified = true)
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) }
nodeManager.handleReceivedUser(
verifiedContact.node_num,
verifiedContact.user ?: User(),
manuallyVerified = true,
)
}
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) {
scope.handledLaunch {
val user = nodeManager.nodeDBbyNodeNum[myNodeNum]?.user ?: User(id = nodeManager.getMyId())
val reaction =
Reaction(
replyId = action.replyId,
user = user,
emoji = action.emoji,
timestamp = nowMillis,
snr = 0f,
rssi = 0,
hopsAway = 0,
packetId = packetId,
status = MessageStatus.QUEUED,
to = action.contactKey.substring(1),
channel = action.contactKey[0].digitToInt(),
)
packetRepository.value.insertReaction(reaction, myNodeNum)
}
}
override fun handleSetOwner(u: MeshUser, myNodeNum: Int) {
Logger.d { "Setting owner: longName=${u.longName}, shortName=${u.shortName}" }
val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) }
nodeManager.handleReceivedUser(myNodeNum, newUser)
}
override fun handleSend(p: DataPacket, myNodeNum: Int) {
commandSender.sendData(p)
serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
dataHandler.value.rememberDataPacket(p, myNodeNum, false)
val bytes = p.bytes ?: ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
if (destNum != myNodeNum) {
val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value
val currentPosition =
when {
provideLocation && position.isValid() -> position
provideLocation ->
nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
?: Position(0.0, 0.0, 0)
else -> Position(0.0, 0.0, 0)
}
commandSender.requestPosition(destNum, currentPosition)
}
}
override fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
nodeManager.removeByNodenum(nodeNum)
commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) }
}
override fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
val u = User.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) }
nodeManager.handleReceivedUser(destNum, u)
}
override fun handleGetRemoteOwner(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) }
}
override fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) }
// Optimistically persist the config locally so CommandSender picks up
// the new values (e.g. hop_limit) immediately instead of waiting for
// the next want_config handshake.
scope.handledLaunch { radioConfigRepository.setLocalConfig(c) }
}
override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) }
// When targeting the local node, optimistically persist the config so the
// UI reflects changes immediately (matching handleSetConfig behaviour).
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.setLocalConfig(c) }
}
}
override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) {
AdminMessage(get_device_metadata_request = true)
} else {
AdminMessage(get_config_request = AdminMessage.ConfigType.fromValue(config))
}
}
}
override fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = ModuleConfig.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) }
c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) }
// Optimistically persist module config locally so the UI reflects the
// new values immediately instead of waiting for the next want_config handshake.
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(c) }
}
}
override fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config))
}
}
override fun handleSetRingtone(destNum: Int, ringtone: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) }
}
override fun handleGetRingtone(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) }
}
override fun handleSetCannedMessages(destNum: Int, messages: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) }
}
override fun handleGetCannedMessages(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_canned_message_module_messages_request = true)
}
}
override fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) }
// Optimistically persist the channel settings locally so the UI
// reflects changes immediately instead of waiting for the next
// want_config handshake.
scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) }
}
}
override fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) }
// When targeting the local node, optimistically persist the channel so
// the UI reflects changes immediately (matching handleSetChannel behaviour).
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) }
}
}
}
override fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) }
}
override fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
commandSender.requestNeighborInfo(requestId, destNum)
}
override fun handleBeginEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) }
}
override fun handleCommitEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) }
}
override fun handleRebootToDfu(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) }
}
override fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
commandSender.requestTelemetry(requestId, destNum, type)
}
override fun handleRequestShutdown(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) }
}
override fun handleRequestReboot(requestId: Int, destNum: Int) {
Logger.i { "Reboot requested for node $destNum" }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) }
}
override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
val otaEvent =
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY)
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
}
override fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
Logger.i { "Factory reset requested for node $destNum" }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) }
}
override fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) }
}
override fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId, wantResponse = true) {
AdminMessage(get_device_connection_status_request = true)
}
}
override fun handleUpdateLastAddress(deviceAddr: String?) {
val currentAddr = meshPrefs.deviceAddress.value
if (deviceAddr != currentAddr) {
Logger.i { "Device address changed, switching database and clearing node DB" }
meshPrefs.setDeviceAddress(deviceAddr)
scope.handledLaunch {
nodeManager.clear()
messageProcessor.value.clearEarlyPackets()
databaseManager.switchActiveDatabase(deviceAddr)
notificationManager.cancelAll()
nodeManager.loadCachedNodeDB()
}
}
}
}

View File

@@ -34,7 +34,6 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
@@ -52,7 +51,6 @@ class MeshConfigFlowManagerImpl(
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val analytics: PlatformAnalytics,
private val commandSender: CommandSender,
private val heartbeatSender: DataLayerHeartbeatSender,
@@ -177,7 +175,7 @@ class MeshConfigFlowManagerImpl(
val entities =
state.nodes.mapNotNull { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
nodeManager.installNodeInfo(nodeInfo)
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
?: run {
Logger.w { "Node ${nodeInfo.num} missing from DB after installNodeInfo; skipping" }
@@ -191,7 +189,6 @@ class MeshConfigFlowManagerImpl(
nodeManager.setNodeDbReady(true)
nodeManager.setAllowNodeDbWrites(true)
serviceRepository.setConnectionState(ConnectionState.Connected)
serviceBroadcasts.broadcastConnection()
connectionManager.value.onNodeDbReady()
}
}

View File

@@ -42,7 +42,7 @@ import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeManager
@@ -52,7 +52,6 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.UiPrefs
@@ -70,8 +69,7 @@ import kotlin.time.DurationUnit
class MeshConnectionManagerImpl(
private val radioInterfaceService: RadioInterfaceService,
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val serviceNotifications: MeshNotificationManager,
private val uiPrefs: UiPrefs,
private val packetHandler: PacketHandler,
private val nodeRepository: NodeRepository,
@@ -200,7 +198,6 @@ class MeshConnectionManagerImpl(
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
connectTimeMsec = nowMillis
// Send a wake-up heartbeat before the config request. The firmware may be in a
@@ -276,8 +273,6 @@ class MeshConnectionManagerImpl(
Logger.d { "device sleep timeout cancelled" }
}
}
serviceBroadcasts.broadcastConnection()
}
private fun handleDisconnected() {
@@ -290,8 +285,6 @@ class MeshConnectionManagerImpl(
DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
)
analytics.track(EVENT_NUM_NODES, DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size))
serviceBroadcasts.broadcastConnection()
}
override fun startConfigOnly() {
@@ -319,7 +312,7 @@ class MeshConnectionManagerImpl(
}
}
override fun onNodeDbReady() {
override suspend fun onNodeDbReady() {
handshakeTimeout?.cancel()
handshakeTimeout = null

View File

@@ -31,14 +31,19 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.destination
import org.meshtastic.core.model.isBroadcast
import org.meshtastic.core.model.isFromLocal
import org.meshtastic.core.model.source
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
@@ -48,7 +53,6 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
@@ -84,9 +88,8 @@ class MeshDataHandlerImpl(
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
private val serviceNotifications: MeshServiceNotifications,
private val serviceNotifications: MeshNotificationManager,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
private val tracerouteHandler: TracerouteHandler,
@@ -112,11 +115,8 @@ class MeshDataHandlerImpl(
val fromUs = myNodeNum == packet.from
dataPacket.status = MessageStatus.RECEIVED
val shouldBroadcast = handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
if (shouldBroadcast) {
serviceBroadcasts.broadcastReceivedData(dataPacket)
}
analytics.track("num_data_receive", DataPair("num_data_receive", 1))
}
@@ -127,50 +127,35 @@ class MeshDataHandlerImpl(
fromUs: Boolean,
logUuid: String?,
logInsertJob: Job?,
): Boolean {
var shouldBroadcast = !fromUs
val decoded = packet.decoded ?: return shouldBroadcast
) {
val decoded = packet.decoded ?: return
when (decoded.portnum) {
PortNum.TEXT_MESSAGE_APP -> handleTextMessage(packet, dataPacket, myNodeNum)
PortNum.NODE_STATUS_APP -> handleNodeStatus(packet, dataPacket, myNodeNum)
PortNum.ALERT_APP -> rememberDataPacket(dataPacket, myNodeNum)
PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum)
PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum)
PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet)
PortNum.TELEMETRY_APP -> telemetryHandler.handleTelemetry(packet, dataPacket, myNodeNum)
else ->
shouldBroadcast =
handleSpecializedDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
else -> handleSpecializedDataPacket(packet, dataPacket, myNodeNum, logUuid, logInsertJob)
}
return shouldBroadcast
}
private fun handleSpecializedDataPacket(
packet: MeshPacket,
dataPacket: DataPacket,
myNodeNum: Int,
fromUs: Boolean,
logUuid: String?,
logInsertJob: Job?,
): Boolean {
var shouldBroadcast = !fromUs
val decoded = packet.decoded ?: return shouldBroadcast
) {
val decoded = packet.decoded ?: return
when (decoded.portnum) {
PortNum.TRACEROUTE_APP -> {
tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob)
shouldBroadcast = false
}
PortNum.ROUTING_APP -> {
handleRouting(packet, dataPacket)
shouldBroadcast = true
}
PortNum.PAXCOUNTER_APP -> {
@@ -191,30 +176,21 @@ class MeshDataHandlerImpl(
PortNum.NEIGHBORINFO_APP -> {
neighborInfoHandler.handleNeighborInfo(packet)
shouldBroadcast = true
}
PortNum.ATAK_PLUGIN,
PortNum.ATAK_PLUGIN_V2,
PortNum.PRIVATE_APP,
-> {
shouldBroadcast = true
}
-> {}
PortNum.RANGE_TEST_APP,
PortNum.DETECTION_SENSOR_APP,
-> {
handleRangeTest(dataPacket, myNodeNum)
shouldBroadcast = true
}
else -> {
// By default, if we don't know what it is, we should probably broadcast it
// so that external apps can handle it.
shouldBroadcast = true
}
else -> {}
}
return shouldBroadcast
}
private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) {
@@ -326,16 +302,13 @@ class MeshDataHandlerImpl(
packetRepository.value.updateReaction(updated)
}
}
serviceBroadcasts.broadcastMessageStatus(requestId, m)
}
}
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
if (dataPacket.dataType !in rememberDataType) return
val fromLocal =
dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum)
val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST
val fromLocal = dataPacket.isFromLocal(myNodeNum)
val toBroadcast = dataPacket.isBroadcast
val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
// contactKey: unique contact key filter (channel)+(nodeId)
@@ -374,7 +347,7 @@ class MeshDataHandlerImpl(
@Suppress("ReturnCount")
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true
val isIgnored = nodeManager.getNodeById(dataPacket.from.orEmpty())?.isIgnored == true
if (isIgnored) return true
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
@@ -388,7 +361,7 @@ class MeshDataHandlerImpl(
updateNotification: Boolean,
) {
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val nodeMuted = nodeManager.getNodeById(dataPacket.from.orEmpty())?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
scope.launch {
@@ -407,19 +380,21 @@ class MeshDataHandlerImpl(
}
private suspend fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
if (packet.source is NodeAddress.Local) {
val myId = nodeManager.getMyId()
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
return nodeManager.getNodeById(myId)?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
return nodeManager.getNodeById(packet.from.orEmpty())?.user?.long_name
?: getStringSuspend(Res.string.unknown_username)
}
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
when (dataPacket.dataType) {
PortNum.TEXT_MESSAGE_APP.value -> {
val message = dataPacket.text!!
val isBroadcast = dataPacket.destination is NodeAddress.Broadcast
val channelName =
if (dataPacket.to == DataPacket.ID_BROADCAST) {
if (isBroadcast) {
radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
} else {
null
@@ -428,7 +403,7 @@ class MeshDataHandlerImpl(
contactKey,
getSenderName(dataPacket),
message,
dataPacket.to == DataPacket.ID_BROADCAST,
isBroadcast,
channelName,
isSilent,
)
@@ -496,15 +471,16 @@ class MeshDataHandlerImpl(
packetRepository.value.getPacketByPacketId(decoded.reply_id)?.let { originalPacket ->
// Skip notification if the original message was filtered
val targetId =
if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
if (originalPacket.source is NodeAddress.Local) originalPacket.to else originalPacket.from
val contactKey = "${originalPacket.channel}$targetId"
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
val nodeMuted = nodeManager.getNodeById(fromId)?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (!isSilent) {
val isBroadcast = originalPacket.destination is NodeAddress.Broadcast
val channelName =
if (originalPacket.to == DataPacket.ID_BROADCAST) {
if (isBroadcast) {
radioConfigRepository.channelSetFlow
.first()
.settings
@@ -517,7 +493,7 @@ class MeshDataHandlerImpl(
contactKey,
getSenderName(dataMapper.toDataPacket(packet)!!),
emoji,
originalPacket.to == DataPacket.ID_BROADCAST,
isBroadcast,
channelName,
isSilent,
)

View File

@@ -217,10 +217,8 @@ class MeshMessageProcessorImpl(
myNodeNum?.let { myNum ->
val from = packet.from
val isOtherNode = myNum != from
nodeManager.updateNode(myNum, withBroadcast = isOtherNode) { node: Node ->
node.copy(lastHeard = nowSeconds.toInt())
}
nodeManager.updateNode(from, withBroadcast = false, channel = packet.channel) { node: Node ->
nodeManager.updateNode(myNum) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) }
nodeManager.updateNode(from, channel = packet.channel) { node: Node ->
val viaMqtt = packet.via_mqtt == true
val isDirect = packet.hop_start == packet.hop_limit
@@ -284,7 +282,7 @@ class MeshMessageProcessorImpl(
lastLocalNodeRefreshMs = now
val myNum = nodeManager.myNodeNum.value ?: return
nodeManager.updateNode(myNum, withBroadcast = false) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) }
nodeManager.updateNode(myNum) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) }
}
private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.value.insert(log) }

View File

@@ -17,7 +17,6 @@
package org.meshtastic.core.data.manager
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshDataHandler
@@ -37,7 +36,6 @@ class MeshRouterImpl(
private val neighborInfoHandlerLazy: Lazy<NeighborInfoHandler>,
private val configFlowManagerLazy: Lazy<MeshConfigFlowManager>,
private val mqttManagerLazy: Lazy<MqttManager>,
private val actionHandlerLazy: Lazy<MeshActionHandler>,
private val xmodemManagerLazy: Lazy<XModemManager>,
) : MeshRouter {
override val dataHandler: MeshDataHandler
@@ -58,9 +56,6 @@ class MeshRouterImpl(
override val mqttManager: MqttManager
get() = mqttManagerLazy.value
override val actionHandler: MeshActionHandler
get() = actionHandlerLazy.value
override val xmodemManager: XModemManager
get() = xmodemManagerLazy.value
}

View File

@@ -26,7 +26,6 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
@@ -35,7 +34,6 @@ import org.meshtastic.proto.NeighborInfo
class NeighborInfoHandlerImpl(
private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val nodeRepository: NodeRepository,
) : NeighborInfoHandler {
@@ -58,9 +56,6 @@ class NeighborInfoHandlerImpl(
Logger.d { "Stored last neighbor info from connected radio" }
}
// Update Node DB
nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) }
// Format for UI response
val requestId = packet.decoded?.request_id ?: 0
val start = startTimes.value[requestId]

View File

@@ -28,20 +28,14 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.clampTimestampToNow
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.NodeIdLookup
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.new_node_seen
@@ -60,19 +54,16 @@ import org.meshtastic.proto.Position as ProtoPosition
@Single(binds = [NodeManager::class, NodeIdLookup::class])
class NodeManagerImpl(
private val nodeRepository: NodeRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
@Named("ServiceScope") private val scope: CoroutineScope,
) : NodeManager {
private val _nodeDBbyNodeNum = atomic(persistentMapOf<Int, Node>())
private val _nodeDBbyID = atomic(persistentMapOf<String, Node>())
override val nodeDBbyNodeNum: Map<Int, Node>
get() = _nodeDBbyNodeNum.value
override val nodeDBbyID: Map<String, Node>
get() = _nodeDBbyID.value
override fun getNodeById(id: String): Node? = _nodeDBbyNodeNum.value.values.firstOrNull { it.user.id == id }
override val isNodeDbReady = MutableStateFlow(false)
override val allowNodeDbWrites = MutableStateFlow(false)
@@ -105,9 +96,6 @@ class NodeManagerImpl(
scope.handledLaunch {
val nodes = nodeRepository.nodeDBbyNum.first()
_nodeDBbyNodeNum.value = persistentMapOf<Int, Node>().putAll(nodes)
val byId = mutableMapOf<String, Node>()
nodes.values.forEach { byId[it.user.id] = it }
_nodeDBbyID.value = persistentMapOf<String, Node>().putAll(byId)
if (myNodeNum.value == null) {
myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum
}
@@ -116,7 +104,6 @@ class NodeManagerImpl(
override fun clear() {
_nodeDBbyNodeNum.value = persistentMapOf()
_nodeDBbyID.value = persistentMapOf()
isNodeDbReady.value = false
allowNodeDbWrites.value = false
myNodeNum.value = null
@@ -149,21 +136,13 @@ class NodeManagerImpl(
return _nodeDBbyNodeNum.value[num]?.user?.id ?: ""
}
override fun getNodes(): List<NodeInfo> = _nodeDBbyNodeNum.value.values.map { it.toNodeInfo() }
override fun removeByNodenum(nodeNum: Int) {
val removed = atomic<Node?>(null)
_nodeDBbyNodeNum.update { map ->
val node = map[nodeNum]
removed.value = node
map.remove(nodeNum)
}
removed.value?.let { node -> _nodeDBbyID.update { it.remove(node.user.id) } }
_nodeDBbyNodeNum.update { it.remove(nodeNum) }
}
internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = _nodeDBbyNodeNum.value[n]
?: run {
val userId = DataPacket.nodeNumToDefaultId(n)
val userId = NodeAddress.numToDefaultId(n)
val defaultUser =
User(
id = userId,
@@ -175,7 +154,7 @@ class NodeManagerImpl(
Node(num = n, user = defaultUser, channel = channel)
}
override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) {
override fun updateNode(nodeNum: Int, channel: Int, transform: (Node) -> Node) {
// Perform read + transform inside update{} to ensure atomicity.
// Without this, concurrent calls for the same nodeNum could read the same snapshot
// and the last writer would silently overwrite the other's changes.
@@ -187,17 +166,10 @@ class NodeManagerImpl(
map.put(nodeNum, transformed)
}
val result = next ?: return
if (result.user.id.isNotEmpty()) {
_nodeDBbyID.update { it.put(result.user.id, result) }
}
if (result.user.id.isNotEmpty() && isNodeDbReady.value) {
scope.handledLaunch { nodeRepository.upsert(result) }
}
if (withBroadcast) {
serviceBroadcasts.broadcastNodeChange(result)
}
}
override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) {
@@ -287,8 +259,8 @@ class NodeManagerImpl(
updateNode(nodeNum) { it.copy(nodeStatus = status?.takeIf { s -> s.isNotEmpty() }) }
}
override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) {
updateNode(info.num, withBroadcast = withBroadcast) { node ->
override fun installNodeInfo(info: ProtoNodeInfo) {
updateNode(info.num) { node ->
var next = node
val user = info.user
if (user != null) {
@@ -334,48 +306,9 @@ class NodeManagerImpl(
return hasExistingUser && isDefaultName && isDefaultHwModel
}
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
override fun toNodeID(nodeNum: Int): String = if (nodeNum == NodeAddress.NODENUM_BROADCAST) {
NodeAddress.ID_BROADCAST
} else {
_nodeDBbyNodeNum.value[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
_nodeDBbyNodeNum.value[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum)
}
private fun Node.toNodeInfo(): NodeInfo = NodeInfo(
num = num,
user =
MeshUser(
id = user.id,
longName = user.long_name,
shortName = user.short_name,
hwModel = user.hw_model,
role = user.role.value,
),
position =
Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits,
)
.takeIf { latitude != 0.0 || longitude != 0.0 },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics =
DeviceMetrics(
batteryLevel = deviceMetrics.battery_level ?: 0,
voltage = deviceMetrics.voltage ?: 0f,
channelUtilization = deviceMetrics.channel_utilization ?: 0f,
airUtilTx = deviceMetrics.air_util_tx ?: 0f,
uptimeSeconds = deviceMetrics.uptime_seconds ?: 0,
),
channel = channel,
environmentMetrics = EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
hopsAway = hopsAway,
nodeStatus = nodeStatus,
)
}

View File

@@ -24,9 +24,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.asDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -47,7 +45,6 @@ import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
@@ -61,7 +58,6 @@ import kotlin.uuid.Uuid
@Single
class PacketHandlerImpl(
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: Lazy<MeshLogRepository>,
private val serviceRepository: ServiceRepository,
@@ -77,11 +73,6 @@ class PacketHandlerImpl(
private val queueMutex = Mutex()
private val queuedPackets = mutableListOf<MeshPacket>()
// Unbounded channel preserves FIFO ordering of fire-and-forget sendToRadio(MeshPacket)
// calls. The non-suspend entry point does trySend (always succeeds for UNLIMITED) and
// a single consumer coroutine enqueues packets under queueMutex in arrival order.
private val outboundChannel = Channel<MeshPacket>(Channel.UNLIMITED)
// Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
// and the queue processor's finally block to prevent restarting a stopped queue.
private var queueStopped = false
@@ -89,20 +80,6 @@ class PacketHandlerImpl(
private val responseMutex = Mutex()
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
init {
// Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket)
// entry point, preserving FIFO across rapid concurrent callers.
scope.launch {
outboundChannel.consumeAsFlow().collect { packet ->
queueMutex.withLock {
queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
queuedPackets.add(packet)
startPacketQueueLocked()
}
}
}
}
override fun sendToRadio(p: ToRadio) {
Logger.d { "Sending to radio ${p.toPIIString()}" }
val b = p.encode()
@@ -126,10 +103,12 @@ class PacketHandlerImpl(
}
}
override fun sendToRadio(packet: MeshPacket) {
// Non-suspend entry point — order-preserving via unbounded channel drained by
// a single consumer coroutine. trySend on UNLIMITED never fails for capacity.
outboundChannel.trySend(packet)
override suspend fun sendToRadio(packet: MeshPacket) {
queueMutex.withLock {
queueStopped = false
queuedPackets.add(packet)
startPacketQueueLocked()
}
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
@@ -247,7 +226,6 @@ class PacketHandlerImpl(
getDataPacketById(packetId)?.let { p ->
if (p.status == m) return@handledLaunch
packetRepository.value.updateMessageStatus(p, m)
serviceBroadcasts.broadcastMessageStatus(packetId, m)
}
}
}

View File

@@ -25,12 +25,12 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
@@ -43,7 +43,6 @@ import kotlin.time.Duration.Companion.milliseconds
class StoreForwardPacketHandlerImpl(
private val nodeManager: NodeManager,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val historyManager: HistoryManager,
private val dataHandler: Lazy<MeshDataHandler>,
@Named("ServiceScope") private val scope: CoroutineScope,
@@ -99,7 +98,7 @@ class StoreForwardPacketHandlerImpl(
encryptedPayload = sfpp.message.toByteArray(),
to =
if (sfpp.encapsulated_to == 0) {
DataPacket.NODENUM_BROADCAST
NodeAddress.NODENUM_BROADCAST
} else {
sfpp.encapsulated_to
},
@@ -125,7 +124,6 @@ class StoreForwardPacketHandlerImpl(
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum.value ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
}
@@ -177,7 +175,7 @@ class StoreForwardPacketHandlerImpl(
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
dataPacket.to = NodeAddress.ID_BROADCAST
}
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
dataHandler.value.rememberDataPacket(u, myNodeNum)

View File

@@ -43,10 +43,10 @@ import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.repository.NodeRepository
@@ -137,10 +137,10 @@ class NodeRepositoryImpl(
/** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */
override fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
?: Node(num = NodeAddress.idToNum(userId) ?: 0, user = getUser(userId))
/** Returns the [User] info for a given [nodeNum]. */
override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
override fun getUser(nodeNum: Int): User = getUser(NodeAddress.numToDefaultId(nodeNum))
private val last4 = 4
@@ -153,13 +153,13 @@ class NodeRepositoryImpl(
val fallbackId = userId.takeLast(last4)
val defaultLong =
if (userId == DataPacket.ID_LOCAL) {
if (NodeAddress.fromString(userId) is NodeAddress.Local) {
ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local"
} else {
"Meshtastic $fallbackId"
}
val defaultShort =
if (userId == DataPacket.ID_LOCAL) {
if (NodeAddress.fromString(userId) is NodeAddress.Local) {
ourNodeInfo.value?.user?.short_name?.takeIf { it.isNotBlank() } ?: "Local"
} else {
fallbackId

View File

@@ -37,6 +37,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Reaction
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
@@ -339,13 +340,13 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
val dao = dbManager.currentDb.value.packetDao()
val packets = findPacketsWithIdInternal(packetId)
val reactions = findReactionsWithIdInternal(packetId)
val fromId = DataPacket.nodeNumToDefaultId(from)
val fromId = NodeAddress.numToDefaultId(from)
val isFromLocalNode = myNodeNum != null && from == myNodeNum
val toId =
if (to == 0 || to == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
if (to == 0 || to == NodeAddress.NODENUM_BROADCAST) {
NodeAddress.ID_BROADCAST
} else {
DataPacket.nodeNumToDefaultId(to)
NodeAddress.numToDefaultId(to)
}
val hashByteString = hash.toByteString()
@@ -353,7 +354,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
packets.forEach { packet ->
// For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches =
packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.ID_LOCAL)
packet.data.from == fromId || (isFromLocalNode && packet.data.from == NodeAddress.ID_LOCAL)
co.touchlab.kermit.Logger.d {
"SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " +
"isFromLocal=$isFromLocalNode fromMatches=$fromMatches " +
@@ -373,7 +374,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
reactions.forEach { reaction ->
val reactionFrom = reaction.userId
// For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == DataPacket.ID_LOCAL)
val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == NodeAddress.ID_LOCAL)
val toMatches = reaction.to == toId

View File

@@ -1,587 +0,0 @@
/*
* 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.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.not
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MeshActionHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val meshPrefs = mock<MeshPrefs>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val databaseManager = mock<DatabaseManager>(MockMode.autofill)
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
private val messageProcessor = mock<MeshMessageProcessor>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val myNodeNumFlow = MutableStateFlow<Int?>(MY_NODE_NUM)
private lateinit var handler: MeshActionHandlerImpl
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
companion object {
private const val MY_NODE_NUM = 12345
private const val REMOTE_NODE_NUM = 67890
}
@BeforeTest
fun setUp() {
every { nodeManager.myNodeNum } returns myNodeNumFlow
every { nodeManager.getMyId() } returns "!12345678"
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
}
private fun createHandler(scope: CoroutineScope): MeshActionHandlerImpl = MeshActionHandlerImpl(
nodeManager = nodeManager,
commandSender = commandSender,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
dataHandler = lazy { dataHandler },
analytics = analytics,
meshPrefs = meshPrefs,
uiPrefs = uiPrefs,
databaseManager = databaseManager,
notificationManager = notificationManager,
messageProcessor = lazy { messageProcessor },
radioConfigRepository = radioConfigRepository,
scope = scope,
)
// ---- handleUpdateLastAddress (device-switch path — P0 critical) ----
@Test
fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress("new_addr")
advanceUntilIdle()
verify { meshPrefs.setDeviceAddress("new_addr") }
verify { nodeManager.clear() }
verifySuspend { messageProcessor.clearEarlyPackets() }
verifySuspend { databaseManager.switchActiveDatabase("new_addr") }
verify { notificationManager.cancelAll() }
verify { nodeManager.loadCachedNodeDB() }
}
@Test
fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr")
handler.handleUpdateLastAddress("same_addr")
advanceUntilIdle()
verify(not) { meshPrefs.setDeviceAddress(any()) }
verify(not) { nodeManager.clear() }
}
@Test
fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress(null)
advanceUntilIdle()
verify { meshPrefs.setDeviceAddress(null) }
verify { nodeManager.clear() }
verifySuspend { databaseManager.switchActiveDatabase(null) }
}
@Test
fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow(null)
handler.handleUpdateLastAddress(null)
advanceUntilIdle()
verify(not) { meshPrefs.setDeviceAddress(any()) }
}
@Test
fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress("new")
advanceUntilIdle()
// Verify critical sequence: clear -> switchDB -> cancelNotifications -> loadCachedNodeDB
verify { nodeManager.clear() }
verifySuspend { databaseManager.switchActiveDatabase("new") }
verify { notificationManager.cancelAll() }
verify { nodeManager.loadCachedNodeDB() }
}
// ---- onServiceAction: null myNodeNum early-return ----
@Test
fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
myNodeNumFlow.value = null
val node = createTestNode(REMOTE_NODE_NUM)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- onServiceAction: Favorite ----
@Test
fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
@Test
fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
// ---- onServiceAction: Ignore ----
@Test
fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false)
handler.onServiceAction(ServiceAction.Ignore(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
verifySuspend { packetRepository.updateFilteredBySender(any(), any()) }
}
// ---- onServiceAction: Mute ----
@Test
fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isMuted = false)
handler.onServiceAction(ServiceAction.Mute(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
// ---- onServiceAction: GetDeviceMetadata ----
@Test
fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- onServiceAction: SendContact ----
@Test
fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
val action = ServiceAction.SendContact(SharedContact())
handler.onServiceAction(action)
advanceUntilIdle()
assertTrue(action.result.isCompleted)
assertTrue(action.result.await())
}
@Test
fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false
val action = ServiceAction.SendContact(SharedContact())
handler.onServiceAction(action)
advanceUntilIdle()
assertTrue(action.result.isCompleted)
assertFalse(action.result.await())
}
// ---- onServiceAction: ImportContact ----
@Test
fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val contact =
SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser"))
handler.onServiceAction(ServiceAction.ImportContact(contact))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleSetOwner ----
@Test
fun handleSetOwner_sendsAdminAndUpdatesLocalNode() {
handler = createHandler(testScope)
val meshUser =
MeshUser(
id = "!12345678",
longName = "Test Long",
shortName = "TL",
hwModel = HardwareModel.UNSET,
isLicensed = false,
)
handler.handleSetOwner(meshUser, MY_NODE_NUM)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleSend ----
@Test
fun handleSend_sendsDataAndBroadcastsStatus() {
handler = createHandler(testScope)
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
handler.handleSend(packet, MY_NODE_NUM)
verify { commandSender.sendData(any()) }
verify { serviceBroadcasts.broadcastMessageStatus(any(), any()) }
verify { dataHandler.rememberDataPacket(any(), any(), any()) }
}
// ---- handleRequestPosition: 3 branches ----
@Test
fun handleRequestPosition_sameNode_doesNothing() {
handler = createHandler(testScope)
handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM)
verify(not) { commandSender.requestPosition(any(), any()) }
}
@Test
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
handler = createHandler(testScope)
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
verify { commandSender.requestPosition(REMOTE_NODE_NUM, validPosition) }
}
@Test
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
handler = createHandler(testScope)
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
val invalidPosition = Position(0.0, 0.0, 0)
handler.handleRequestPosition(REMOTE_NODE_NUM, invalidPosition, MY_NODE_NUM)
// Falls back to Position(0.0, 0.0, 0) when node has no position in DB
verify { commandSender.requestPosition(any(), any()) }
}
@Test
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
handler = createHandler(testScope)
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
// Should send zero position regardless of valid input
verify { commandSender.requestPosition(any(), any()) }
}
// ---- handleSetConfig: optimistic persist ----
@Test
fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit
val config = Config(lora = Config.LoRaConfig(hop_limit = 5))
val payload = config.encode()
handler.handleSetConfig(payload, MY_NODE_NUM)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.setLocalConfig(any()) }
}
// ---- handleSetModuleConfig: conditional persist ----
@Test
fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val payload = moduleConfig.encode()
handler.handleSetModuleConfig(0, MY_NODE_NUM, payload)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.setLocalModuleConfig(any()) }
}
@Test
fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val payload = moduleConfig.encode()
handler.handleSetModuleConfig(0, REMOTE_NODE_NUM, payload)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend(not) { radioConfigRepository.setLocalModuleConfig(any()) }
}
// ---- handleSetChannel: null payload guard ----
@Test
fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit
val channel = Channel(index = 1)
val payload = channel.encode()
handler.handleSetChannel(payload, MY_NODE_NUM)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.updateChannelSettings(any()) }
}
@Test
fun handleSetChannel_nullPayload_doesNothing() {
handler = createHandler(testScope)
handler.handleSetChannel(null, MY_NODE_NUM)
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRemoveByNodenum ----
@Test
fun handleRemoveByNodenum_removesAndSendsAdmin() {
handler = createHandler(testScope)
handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM)
verify { nodeManager.removeByNodenum(REMOTE_NODE_NUM) }
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleSetRemoteOwner ----
@Test
fun handleSetRemoteOwner_decodesAndSendsAdmin() {
handler = createHandler(testScope)
val user = User(id = "!remote01", long_name = "Remote", short_name = "RM")
val payload = user.encode()
handler.handleSetRemoteOwner(1, REMOTE_NODE_NUM, payload)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleGetRemoteConfig: sessionkey vs regular ----
@Test
fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() {
handler = createHandler(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() {
handler = createHandler(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleSetRemoteChannel: null payload guard ----
@Test
fun handleSetRemoteChannel_nullPayload_doesNothing() {
handler = createHandler(testScope)
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null)
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() {
handler = createHandler(testScope)
val channel = Channel(index = 2)
val payload = channel.encode()
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, payload)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRequestRebootOta: null hash ----
@Test
fun handleRequestRebootOta_withNullHash_sendsAdmin() {
handler = createHandler(testScope)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleRequestRebootOta_withHash_sendsAdmin() {
handler = createHandler(testScope)
val hash = byteArrayOf(0x01, 0x02, 0x03)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRequestNodedbReset ----
@Test
fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() {
handler = createHandler(testScope)
handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- Helper ----
private fun createTestNode(
num: Int,
isFavorite: Boolean = false,
isIgnored: Boolean = false,
isMuted: Boolean = false,
): Node = Node(
num = num,
user = User(id = "!${num.toString(16).padStart(8, '0')}", long_name = "Node $num", short_name = "N$num"),
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
)
}

View File

@@ -41,7 +41,6 @@ import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
@@ -61,7 +60,6 @@ class MeshConfigFlowManagerImplTest {
private val nodeRepository = mock<NodeRepository>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
@@ -101,7 +99,6 @@ class MeshConfigFlowManagerImplTest {
nodeRepository = nodeRepository,
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
serviceBroadcasts = serviceBroadcasts,
analytics = analytics,
commandSender = commandSender,
heartbeatSender = DataLayerHeartbeatSender(packetHandler),
@@ -306,11 +303,10 @@ class MeshConfigFlowManagerImplTest {
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
verify { nodeManager.installNodeInfo(any(), withBroadcast = false) }
verify { nodeManager.installNodeInfo(any()) }
verify { nodeManager.setNodeDbReady(true) }
verify { nodeManager.setAllowNodeDbWrites(true) }
verify { serviceBroadcasts.broadcastConnection() }
verify { connectionManager.onNodeDbReady() }
verifySuspend { connectionManager.onNodeDbReady() }
}
@Test
@@ -334,7 +330,7 @@ class MeshConfigFlowManagerImplTest {
advanceUntilIdle()
verify { nodeManager.setNodeDbReady(true) }
verify { connectionManager.onNodeDbReady() }
verifySuspend { connectionManager.onNodeDbReady() }
}
// ---------- Unknown config_complete_id ----------
@@ -402,7 +398,7 @@ class MeshConfigFlowManagerImplTest {
advanceUntilIdle()
verify { nodeManager.setNodeDbReady(true) }
verify { connectionManager.onNodeDbReady() }
verifySuspend { connectionManager.onNodeDbReady() }
// After complete, newNodeCount should be 0 (state is Complete)
assertEquals(0, manager.newNodeCount)

View File

@@ -24,6 +24,7 @@ import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
@@ -39,7 +40,7 @@ import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeManager
@@ -48,7 +49,6 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.UiPrefs
@@ -66,8 +66,8 @@ import kotlin.test.assertEquals
class MeshConnectionManagerImplTest {
private val radioInterfaceService = mock<RadioInterfaceService>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val serviceNotifications = mock<MeshServiceNotifications>(MockMode.autofill)
private val serviceNotifications = mock<MeshNotificationManager>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
private val nodeRepository = FakeNodeRepository()
@@ -105,7 +105,7 @@ class MeshConnectionManagerImplTest {
connectionStateFlow.value = call.arg<ConnectionState>(0)
}
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { commandSender.sendAdmin(any(), any(), any(), any()) } returns Unit
everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
@@ -116,7 +116,6 @@ class MeshConnectionManagerImplTest {
private fun createManager(scope: CoroutineScope): MeshConnectionManagerImpl = MeshConnectionManagerImpl(
radioInterfaceService,
serviceRepository,
serviceBroadcasts,
serviceNotifications,
uiPrefs,
packetHandler,
@@ -149,7 +148,6 @@ class MeshConnectionManagerImplTest {
serviceRepository.connectionState.value,
"State should be Connecting after radio Connected",
)
verify { serviceBroadcasts.broadcastConnection() }
}
@Test
@@ -290,10 +288,10 @@ class MeshConnectionManagerImplTest {
store_forward = ModuleConfig.StoreForwardConfig(enabled = true),
)
moduleConfigFlow.value = moduleConfig
every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
everySuspend { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
every { mqttManager.startProxy(any(), any()) } returns Unit
every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
everySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
every { nodeManager.getMyNodeInfo() } returns null
manager = createManager(backgroundScope)
@@ -301,7 +299,7 @@ class MeshConnectionManagerImplTest {
advanceUntilIdle()
verify { mqttManager.startProxy(true, true) }
verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
verifySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
}
@Test

View File

@@ -34,9 +34,10 @@ import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
@@ -45,7 +46,6 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
@@ -72,9 +72,8 @@ class MeshDataHandlerTest {
private val packetHandler: PacketHandler = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private val dataMapper: MeshDataMapper = mock(MockMode.autofill)
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
@@ -96,7 +95,6 @@ class MeshDataHandlerTest {
packetHandler = packetHandler,
serviceRepository = serviceRepository,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
notificationManager = notificationManager,
serviceNotifications = serviceNotifications,
analytics = analytics,
@@ -114,7 +112,7 @@ class MeshDataHandlerTest {
// Default: mapper returns null for empty packets, which is the safe default
every { dataMapper.toDataPacket(any()) } returns null
// Stub commonly accessed properties to avoid NPE from autofill
every { nodeManager.nodeDBbyID } returns emptyMap()
every { nodeManager.getNodeById(any()) } returns null
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
}
@@ -132,7 +130,6 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, 123)
// Should not broadcast if dataMapper returns null
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
@@ -146,8 +143,8 @@ class MeshDataHandlerTest {
)
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(myNodeNum),
to = DataPacket.ID_BROADCAST,
from = NodeAddress.numToDefaultId(myNodeNum),
to = NodeAddress.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
@@ -156,8 +153,7 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, myNodeNum)
// Position from local node: shouldBroadcast stays as !fromUs = false
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
// Position from local node — no further action expected
}
@Test
@@ -167,16 +163,14 @@ class MeshDataHandlerTest {
val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP))
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(remoteNum),
to = DataPacket.ID_BROADCAST,
from = NodeAddress.numToDefaultId(remoteNum),
to = NodeAddress.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
@@ -185,7 +179,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!other",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
@@ -211,7 +205,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
@@ -238,7 +232,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
@@ -261,7 +255,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
@@ -286,7 +280,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = pax.encode().toByteString(),
dataType = PortNum.PAXCOUNTER_APP.value,
)
@@ -318,7 +312,6 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, 123)
verify { tracerouteHandler.handleTraceroute(packet, any(), any()) }
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- NeighborInfo handling ---
@@ -334,7 +327,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = ni.encode().toByteString(),
dataType = PortNum.NEIGHBORINFO_APP.value,
)
@@ -343,7 +336,6 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, 123)
verify { neighborInfoHandler.handleNeighborInfo(packet) }
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Store-and-Forward handling ---
@@ -358,7 +350,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = byteArrayOf().toByteString(),
dataType = PortNum.STORE_FORWARD_APP.value,
)
@@ -383,7 +375,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
@@ -407,7 +399,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
@@ -415,8 +407,6 @@ class MeshDataHandlerTest {
every { nodeManager.toNodeID(456) } returns "!remote"
handler.handleReceivedData(packet, 123)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Telemetry handling ---
@@ -436,7 +426,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
@@ -464,7 +454,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
@@ -491,7 +481,7 @@ class MeshDataHandlerTest {
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
@@ -500,11 +490,8 @@ class MeshDataHandlerTest {
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter(any(), any()) } returns false
// Provide sender node so getSenderName() doesn't fall back to getString (requires Skiko)
every { nodeManager.nodeDBbyID } returns
mapOf(
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
every { nodeManager.getNodeById("!remote") } returns
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU"))
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
@@ -525,7 +512,7 @@ class MeshDataHandlerTest {
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
@@ -598,7 +585,7 @@ class MeshDataHandlerTest {
DataPacket(
id = 55,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "test".encodeToByteArray().toByteString(),
dataType = PortNum.RANGE_TEST_APP.value,
)
@@ -606,11 +593,8 @@ class MeshDataHandlerTest {
everySuspend { packetRepository.findPacketsWithId(55) } returns emptyList()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter(any(), any()) } returns false
every { nodeManager.nodeDBbyID } returns
mapOf(
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
every { nodeManager.getNodeById("!remote") } returns
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU"))
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
@@ -629,7 +613,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = admin.encode().toByteString(),
dataType = PortNum.ADMIN_APP.value,
)
@@ -658,13 +642,13 @@ class MeshDataHandlerTest {
DataPacket(
id = 77,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "spam content".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(77) } returns emptyList()
every { nodeManager.nodeDBbyID } returns emptyMap()
every { nodeManager.getNodeById(any()) } returns null
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter("spam content", false) } returns true
@@ -688,14 +672,14 @@ class MeshDataHandlerTest {
DataPacket(
id = 88,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(88) } returns emptyList()
every { nodeManager.nodeDBbyID } returns
mapOf("!remote" to Node(num = 456, user = User(id = "!remote"), isIgnored = true))
every { nodeManager.getNodeById("!remote") } returns
Node(num = 456, user = User(id = "!remote"), isIgnored = true)
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
handler.handleReceivedData(packet, 123)

View File

@@ -251,7 +251,7 @@ class MeshMessageProcessorImplTest {
advanceUntilIdle()
// Should have called updateNode for myNodeNum (lastHeard update)
verify { nodeManager.updateNode(myNodeNum, withBroadcast = true, any(), any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any()) }
}
@Test
@@ -273,7 +273,7 @@ class MeshMessageProcessorImplTest {
advanceUntilIdle()
// Should have called updateNode for the sender
verify { nodeManager.updateNode(senderNode, withBroadcast = false, any(), any()) }
verify { nodeManager.updateNode(senderNode, any(), any()) }
}
// ---------- handleReceivedMeshPacket: null decoded ----------

View File

@@ -19,14 +19,7 @@ package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshDataHandler
@@ -47,7 +40,6 @@ class MeshRouterImplTest {
private val neighborInfoHandler = mock<NeighborInfoHandler>(MockMode.autofill)
private val configFlowManager = mock<MeshConfigFlowManager>(MockMode.autofill)
private val mqttManager = mock<MqttManager>(MockMode.autofill)
private val actionHandler = mock<MeshActionHandler>(MockMode.autofill)
private val xmodemManager = mock<XModemManager>(MockMode.autofill)
private val configHandler =
@@ -70,7 +62,6 @@ class MeshRouterImplTest {
private lateinit var neighborInfoHandlerLazy: TrackingLazy<NeighborInfoHandler>
private lateinit var configFlowManagerLazy: TrackingLazy<MeshConfigFlowManager>
private lateinit var mqttManagerLazy: TrackingLazy<MqttManager>
private lateinit var actionHandlerLazy: TrackingLazy<MeshActionHandler>
private lateinit var xmodemManagerLazy: TrackingLazy<XModemManager>
private lateinit var router: MeshRouterImpl
@@ -83,7 +74,6 @@ class MeshRouterImplTest {
neighborInfoHandlerLazy = TrackingLazy { neighborInfoHandler }
configFlowManagerLazy = TrackingLazy { configFlowManager }
mqttManagerLazy = TrackingLazy { mqttManager }
actionHandlerLazy = TrackingLazy { actionHandler }
xmodemManagerLazy = TrackingLazy { xmodemManager }
router =
@@ -94,36 +84,10 @@ class MeshRouterImplTest {
neighborInfoHandlerLazy = neighborInfoHandlerLazy,
configFlowManagerLazy = configFlowManagerLazy,
mqttManagerLazy = mqttManagerLazy,
actionHandlerLazy = actionHandlerLazy,
xmodemManagerLazy = xmodemManagerLazy,
)
}
@Test
fun `send message routing uses the action handler lazily`() {
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
assertAllHandlersUninitialized()
router.actionHandler.handleSend(packet, 12345)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(dataHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
verify { actionHandler.handleSend(packet, 12345) }
}
@Test
fun `request position routing uses the action handler lazily`() {
val position = Position(latitude = 37.7749, longitude = -122.4194, altitude = 10)
router.actionHandler.handleRequestPosition(destNum = 67890, position = position, myNodeNum = 12345)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
verify { actionHandler.handleRequestPosition(67890, position, 12345) }
}
@Test
fun `traceroute routing uses the traceroute handler lazily`() {
assertAllHandlersUninitialized()
@@ -131,31 +95,22 @@ class MeshRouterImplTest {
router.tracerouteHandler.recordStartTime(77)
assertTrue(tracerouteHandlerLazy.isInitialized())
assertFalse(actionHandlerLazy.isInitialized())
assertFalse(dataHandlerLazy.isInitialized())
verify { tracerouteHandler.recordStartTime(77) }
}
@Test
fun `admin command routing uses the action handler lazily`() {
fun `handlers are initialized independently`() {
assertAllHandlersUninitialized()
router.actionHandler.handleGetRemoteConfig(id = 42, destNum = 67890, config = 7)
assertTrue(actionHandlerLazy.isInitialized())
router.dataHandler
assertTrue(dataHandlerLazy.isInitialized())
assertFalse(configHandlerLazy.isInitialized())
verify { actionHandler.handleGetRemoteConfig(42, 67890, 7) }
}
@Test
fun `service actions are passed through unchanged to the action handler`() = runTest {
val action = ServiceAction.Favorite(Node(num = 67890))
router.actionHandler.onServiceAction(action)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(dataHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
verifySuspend { actionHandler.onServiceAction(action) }
router.configHandler
assertTrue(configHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
}
private fun assertAllHandlersUninitialized() {
@@ -165,7 +120,6 @@ class MeshRouterImplTest {
assertFalse(neighborInfoHandlerLazy.isInitialized())
assertFalse(configFlowManagerLazy.isInitialized())
assertFalse(mqttManagerLazy.isInitialized())
assertFalse(actionHandlerLazy.isInitialized())
assertFalse(xmodemManagerLazy.isInitialized())
}

View File

@@ -21,11 +21,10 @@ import dev.mokkery.mock
import kotlinx.coroutines.test.TestScope
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
@@ -43,7 +42,6 @@ import org.meshtastic.proto.Position as ProtoPosition
class NodeManagerImplTest {
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private val testScope = TestScope()
@@ -51,7 +49,7 @@ class NodeManagerImplTest {
@BeforeTest
fun setUp() {
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope)
nodeManager = NodeManagerImpl(nodeRepository, notificationManager, testScope)
}
@Test
@@ -62,7 +60,7 @@ class NodeManagerImplTest {
assertNotNull(result)
assertEquals(nodeNum, result.num)
assertTrue(result.user.long_name.startsWith("Meshtastic"))
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), result.user.id)
assertEquals(NodeAddress.numToDefaultId(nodeNum), result.user.id)
}
@Test
@@ -192,20 +190,20 @@ class NodeManagerImplTest {
nodeManager.clear()
assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty())
assertTrue(nodeManager.nodeDBbyID.isEmpty())
assertNull(nodeManager.getNodeById("!000004d2"))
assertNull(nodeManager.myNodeNum.value)
}
@Test
fun `toNodeID returns broadcast ID for broadcast nodeNum`() {
val result = nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST)
assertEquals(DataPacket.ID_BROADCAST, result)
val result = nodeManager.toNodeID(NodeAddress.NODENUM_BROADCAST)
assertEquals(NodeAddress.ID_BROADCAST, result)
}
@Test
fun `toNodeID returns default hex ID for unknown node`() {
val result = nodeManager.toNodeID(0x1234)
assertEquals(DataPacket.nodeNumToDefaultId(0x1234), result)
assertEquals(NodeAddress.numToDefaultId(0x1234), result)
}
@Test
@@ -218,18 +216,18 @@ class NodeManagerImplTest {
}
@Test
fun `removeByNodenum removes node from both maps`() {
fun `removeByNodenum removes node from map`() {
val nodeNum = 1234
nodeManager.updateNode(nodeNum) {
Node(num = nodeNum, user = User(id = "!testnode", long_name = "Test", short_name = "T"))
}
assertTrue(nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
assertTrue(nodeManager.nodeDBbyID.containsKey("!testnode"))
assertNotNull(nodeManager.getNodeById("!testnode"))
nodeManager.removeByNodenum(nodeNum)
assertTrue(!nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
assertTrue(!nodeManager.nodeDBbyID.containsKey("!testnode"))
assertNull(nodeManager.getNodeById("!testnode"))
}
@Test

View File

@@ -34,7 +34,6 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
@@ -48,7 +47,6 @@ import kotlin.test.assertNotNull
class PacketHandlerImplTest {
private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val meshLogRepository: MeshLogRepository = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
@@ -67,7 +65,6 @@ class PacketHandlerImplTest {
handler =
PacketHandlerImpl(
lazy { packetRepository },
serviceBroadcasts,
radioInterfaceService,
lazy { meshLogRepository },
serviceRepository,

View File

@@ -27,6 +27,7 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
@@ -78,8 +79,8 @@ class TelemetryPacketHandlerImplTest {
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
id = 1,
time = 1700000000000L,
to = DataPacket.ID_BROADCAST,
from = DataPacket.nodeNumToDefaultId(from),
to = NodeAddress.ID_BROADCAST,
from = NodeAddress.numToDefaultId(from),
bytes = null,
dataType = PortNum.TELEMETRY_APP.value,
)
@@ -97,7 +98,7 @@ class TelemetryPacketHandlerImplTest {
advanceUntilIdle()
verify { connectionManager.updateTelemetry(any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any()) }
}
// ---------- Device metrics from remote node ----------
@@ -112,7 +113,7 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(remoteNodeNum, any(), any()) }
}
// ---------- Environment metrics ----------
@@ -130,7 +131,7 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(remoteNodeNum, any(), any()) }
}
// ---------- Power metrics ----------
@@ -144,7 +145,7 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(remoteNodeNum, any(), any()) }
}
// ---------- Telemetry time handling ----------
@@ -158,7 +159,7 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any()) }
}
// ---------- Null payload ----------

View File

@@ -32,11 +32,11 @@ import kotlinx.coroutines.test.runTest
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
@@ -50,7 +50,6 @@ class StoreForwardPacketHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val historyManager = mock<HistoryManager>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
@@ -69,7 +68,6 @@ class StoreForwardPacketHandlerImplTest {
StoreForwardPacketHandlerImpl(
nodeManager = nodeManager,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
historyManager = historyManager,
dataHandler = lazy { dataHandler },
scope = testScope,
@@ -89,8 +87,8 @@ class StoreForwardPacketHandlerImplTest {
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
id = 1,
time = 1700000000000L,
to = DataPacket.ID_BROADCAST,
from = DataPacket.nodeNumToDefaultId(from),
to = NodeAddress.ID_BROADCAST,
from = NodeAddress.numToDefaultId(from),
bytes = null,
dataType = PortNum.STORE_FORWARD_APP.value,
)
@@ -222,7 +220,6 @@ class StoreForwardPacketHandlerImplTest {
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
verify { serviceBroadcasts.broadcastMessageStatus(42, any()) }
}
// ---------- SF++: CANON_ANNOUNCE ----------

View File

@@ -32,6 +32,7 @@ import org.meshtastic.core.database.MeshtasticDatabaseConstructor
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.NodeAddress
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
import org.robolectric.annotation.Config
@@ -166,7 +167,7 @@ class MigrationTest {
contact_key = "$channel!broadcast",
received_time = nowMillis,
read = false,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = channel, text = text),
data = DataPacket(to = NodeAddress.ID_BROADCAST, channel = channel, text = text),
)
packetDao.insert(packet)
}

View File

@@ -26,12 +26,7 @@ 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.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
@@ -46,32 +41,7 @@ data class NodeWithRelations(
@Relation(entity = MetadataEntity::class, parentColumns = ["num"], entityColumns = ["num"])
val metadata: MetadataEntity?,
) {
fun toModel() = with(node) {
Node(
num = num,
metadata = metadata?.proto,
user = user,
position = position,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(),
powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(),
paxcounter = paxcounter,
publicKey = publicKey ?: user.public_key,
notes = notes,
manuallyVerified = manuallyVerified,
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
}
fun toModel() = node.toModel().copy(metadata = metadata?.proto, manuallyVerified = node.manuallyVerified)
fun toEntity() = with(node) {
NodeEntity(
@@ -211,49 +181,4 @@ data class NodeEntity(
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
fun toNodeInfo() = NodeInfo(
num = num,
user =
MeshUser(
id = user.id,
longName = user.long_name,
shortName = user.short_name,
hwModel = user.hw_model,
role = user.role.value,
)
.takeIf { user.id.isNotEmpty() },
position =
Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits,
)
.takeIf { it.isValid() },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics =
DeviceMetrics(
time = deviceTelemetry.time,
batteryLevel = deviceMetrics?.battery_level ?: 0,
voltage = deviceMetrics?.voltage ?: 0f,
channelUtilization = deviceMetrics?.channel_utilization ?: 0f,
airUtilTx = deviceMetrics?.air_util_tx ?: 0f,
uptimeSeconds = deviceMetrics?.uptime_seconds ?: 0,
),
channel = channel,
environmentMetrics =
EnvironmentMetrics.fromTelemetryProto(
environmentTelemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics(),
environmentTelemetry.time,
),
hopsAway = hopsAway,
nodeStatus = nodeStatus,
)
}

View File

@@ -28,6 +28,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.util.getShortDateTime
@@ -38,7 +39,7 @@ data class PacketEntity(
) {
suspend fun toMessage(getNode: suspend (userId: String?) -> Node) = with(packet) {
val node = getNode(data.from)
val isFromLocal = node.user.id == DataPacket.ID_LOCAL || (myNodeNum != 0 && node.num == myNodeNum)
val isFromLocal = node.user.id == NodeAddress.ID_LOCAL || (myNodeNum != 0 && node.num == myNodeNum)
Message(
uuid = uuid,
receivedTime = received_time,

View File

@@ -20,6 +20,7 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.FromRadio
@@ -41,7 +42,7 @@ class ConvertersTest {
fun `data packet string converter round trips`() {
val packet =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "hello mesh".encodeToByteArray().toByteString(),
dataType = 1,
from = "!12345678",

View File

@@ -27,6 +27,7 @@ import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.proto.PortNum
import kotlin.test.AfterTest
import kotlin.test.Test
@@ -57,7 +58,7 @@ abstract class CommonPacketDaoTest {
private val myNodeNum: Int
get() = myNodeInfo.myNodeNum
private val testContactKeys = listOf("0${DataPacket.ID_BROADCAST}", "1!test1234")
private val testContactKeys = listOf("0${NodeAddress.ID_BROADCAST}", "1!test1234")
private fun generateTestPackets(myNodeNum: Int) = testContactKeys.flatMap { contactKey ->
List(SAMPLE_SIZE) {
@@ -70,7 +71,7 @@ abstract class CommonPacketDaoTest {
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "Message $it!".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
@@ -157,7 +158,7 @@ abstract class CommonPacketDaoTest {
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "Queued".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
status = MessageStatus.QUEUED,
@@ -191,12 +192,12 @@ abstract class CommonPacketDaoTest {
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.WAYPOINT_APP.value,
contact_key = "0${DataPacket.ID_BROADCAST}",
contact_key = "0${NodeAddress.ID_BROADCAST}",
received_time = nowMillis,
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "Waypoint".encodeToByteArray().toByteString(),
dataType = PortNum.WAYPOINT_APP.value,
),
@@ -231,7 +232,7 @@ abstract class CommonPacketDaoTest {
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
@@ -251,7 +252,7 @@ abstract class CommonPacketDaoTest {
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
@@ -293,7 +294,7 @@ abstract class CommonPacketDaoTest {
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "Chunk $id".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),

View File

@@ -35,7 +35,6 @@ src/commonMain/kotlin/org/meshtastic/core/domain/
├── ImportProfileUseCase.kt
├── InstallProfileUseCase.kt
├── IsOtaCapableUseCase.kt
├── MeshLocationUseCase.kt
├── ProcessRadioResponseUseCase.kt
├── RadioConfigUseCase.kt
├── SetAppIntroCompletedUseCase.kt

View File

@@ -29,9 +29,8 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import kotlin.time.Duration.Companion.seconds
@@ -55,7 +54,7 @@ import kotlin.time.Duration.Companion.seconds
@Single
open class EnsureRemoteAdminSessionUseCase(
private val sessionManager: SessionManager,
private val meshActionHandler: MeshActionHandler,
private val radioController: RadioController,
private val serviceRepository: ServiceRepository,
@Named("ServiceScope") private val serviceScope: CoroutineScope,
) {
@@ -94,7 +93,7 @@ open class EnsureRemoteAdminSessionUseCase(
sessionManager.sessionRefreshFlow.filter { it == destNum }.first()
}
try {
meshActionHandler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum))
radioController.refreshMetadata(destNum)
refreshed.await()
EnsureSessionResult.Refreshed
} finally {

View File

@@ -1,34 +0,0 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.model.RadioController
/** Use case for controlling location sharing with the mesh. */
@Single
open class MeshLocationUseCase constructor(private val radioController: RadioController) {
/** Starts providing the phone's location to the mesh. */
fun startProvidingLocation() {
radioController.startProvideLocation()
}
/** Stops providing the phone's location to the mesh. */
fun stopProvidingLocation() {
radioController.stopProvideLocation()
}
}

View File

@@ -1,27 +0,0 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetAppIntroCompletedUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: Boolean) {
uiPrefs.setAppIntroCompleted(value)
}
}

View File

@@ -1,30 +0,0 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.database.DatabaseConstants
/** Use case for setting the database cache limit. */
@Single
open class SetDatabaseCacheLimitUseCase constructor(private val databaseManager: DatabaseManager) {
operator fun invoke(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
databaseManager.setCacheLimit(clamped)
}
}

View File

@@ -1,27 +0,0 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetLocaleUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: String) {
uiPrefs.setLocale(value)
}
}

View File

@@ -1,30 +0,0 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.NotificationPrefs
/** Use case for updating application-level notification preferences. */
@Single
class SetNotificationSettingsUseCase(private val notificationPrefs: NotificationPrefs) {
fun setMessagesEnabled(enabled: Boolean) = notificationPrefs.setMessagesEnabled(enabled)
fun setNodeEventsEnabled(enabled: Boolean) = notificationPrefs.setNodeEventsEnabled(enabled)
fun setLowBatteryEnabled(enabled: Boolean) = notificationPrefs.setLowBatteryEnabled(enabled)
}

View File

@@ -1,27 +0,0 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetProvideLocationUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
}
}

View File

@@ -1,27 +0,0 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetThemeUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: Int) {
uiPrefs.setTheme(value)
}
}

View File

@@ -1,28 +0,0 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.AnalyticsPrefs
/** Use case for toggling the analytics preference. */
@Single
open class ToggleAnalyticsUseCase constructor(private val analyticsPrefs: AnalyticsPrefs) {
open operator fun invoke() {
analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value)
}
}

View File

@@ -1,28 +0,0 @@
/*
* 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.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.HomoglyphPrefs
/** Use case for toggling the homoglyph encoding preference. */
@Single
open class ToggleHomoglyphEncodingUseCase constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
open operator fun invoke() {
homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value)
}
}

View File

@@ -34,9 +34,8 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import kotlin.test.Test
@@ -68,9 +67,14 @@ class EnsureRemoteAdminSessionUseCaseTest {
@Test
fun `returns Disconnected without dispatching when not connected`() = runTest {
val sessionManager = stubSessionManager()
val handler = mock<MeshActionHandler>(MockMode.autofill)
val controller = mock<RadioController>(MockMode.autofill)
val useCase =
EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(ConnectionState.Disconnected), this)
EnsureRemoteAdminSessionUseCase(
sessionManager,
controller,
connectedRepo(ConnectionState.Disconnected),
this,
)
val result = useCase(destNum)
@@ -81,8 +85,8 @@ class EnsureRemoteAdminSessionUseCaseTest {
fun `returns AlreadyActive without dispatching when status already Active`() = runTest {
val active = SessionStatus.Active(Clock.System.now())
val sessionManager = stubSessionManager(initialStatus = active)
val handler = mock<MeshActionHandler>(MockMode.autofill)
val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this)
val controller = mock<RadioController>(MockMode.autofill)
val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, controller, connectedRepo(), this)
val result = useCase(destNum)
@@ -93,30 +97,30 @@ class EnsureRemoteAdminSessionUseCaseTest {
fun `dispatches metadata request and returns Refreshed when refresh flow emits`() = runTest {
val refresh = MutableSharedFlow<Int>(extraBufferCapacity = 8)
val sessionManager = stubSessionManager(refreshFlow = refresh)
val handler = mock<MeshActionHandler>(MockMode.autofill)
val controller = mock<RadioController>(MockMode.autofill)
// Simulate the radio responding by emitting on the refresh flow when the metadata request fires.
everySuspend { handler.onServiceAction(any()) } calls
everySuspend { controller.refreshMetadata(any()) } calls
{
refresh.tryEmit(destNum)
Unit
}
val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this)
val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, controller, connectedRepo(), this)
val result = useCase(destNum)
assertEquals(EnsureSessionResult.Refreshed, result)
verifySuspend { handler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) }
verifySuspend { controller.refreshMetadata(destNum) }
}
@Test
fun `returns Timeout when no refresh arrives within deadline`() = runTest {
val refresh = MutableSharedFlow<Int>(extraBufferCapacity = 8)
val sessionManager = stubSessionManager(refreshFlow = refresh)
val handler = mock<MeshActionHandler>(MockMode.autofill)
everySuspend { handler.onServiceAction(any()) } returns Unit
val controller = mock<RadioController>(MockMode.autofill)
everySuspend { controller.refreshMetadata(any()) } returns Unit
val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this)
val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, controller, connectedRepo(), this)
var observed: EnsureSessionResult? = null
val job = launch { observed = useCase(destNum) }

View File

@@ -1,46 +0,0 @@
/*
* 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.domain.usecase.settings
import org.meshtastic.core.testing.FakeRadioController
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
class MeshLocationUseCaseTest {
private lateinit var radioController: FakeRadioController
private lateinit var useCase: MeshLocationUseCase
@BeforeTest
fun setUp() {
radioController = FakeRadioController()
useCase = MeshLocationUseCase(radioController)
}
@Test
fun `startProvidingLocation calls radioController`() {
useCase.startProvidingLocation()
assertTrue(radioController.startProvideLocationCalled)
}
@Test
fun `stopProvidingLocation calls radioController`() {
useCase.stopProvidingLocation()
assertTrue(radioController.stopProvideLocationCalled)
}
}

View File

@@ -1,49 +0,0 @@
/*
* 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.domain.usecase.settings
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.database.DatabaseConstants
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetDatabaseCacheLimitUseCaseTest {
private lateinit var databaseManager: DatabaseManager
private lateinit var useCase: SetDatabaseCacheLimitUseCase
@BeforeTest
fun setUp() {
databaseManager = mock(dev.mokkery.MockMode.autofill)
useCase = SetDatabaseCacheLimitUseCase(databaseManager)
}
@Test
fun `invoke calls setCacheLimit with clamped value`() {
// Act & Assert
useCase(0)
verify { databaseManager.setCacheLimit(DatabaseConstants.MIN_CACHE_LIMIT) }
useCase(100)
verify { databaseManager.setCacheLimit(DatabaseConstants.MAX_CACHE_LIMIT) }
useCase(5)
verify { databaseManager.setCacheLimit(5) }
}
}

View File

@@ -1,58 +0,0 @@
/*
* 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.domain.usecase.settings
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.repository.NotificationPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetNotificationSettingsUseCaseTest {
private val notificationPrefs: NotificationPrefs = mock()
private lateinit var useCase: SetNotificationSettingsUseCase
@BeforeTest
fun setUp() {
useCase = SetNotificationSettingsUseCase(notificationPrefs)
}
@Test
fun `setMessagesEnabled calls notificationPrefs`() {
every { notificationPrefs.setMessagesEnabled(any()) } returns Unit
useCase.setMessagesEnabled(true)
verify { notificationPrefs.setMessagesEnabled(true) }
}
@Test
fun `setNodeEventsEnabled calls notificationPrefs`() {
every { notificationPrefs.setNodeEventsEnabled(any()) } returns Unit
useCase.setNodeEventsEnabled(false)
verify { notificationPrefs.setNodeEventsEnabled(false) }
}
@Test
fun `setLowBatteryEnabled calls notificationPrefs`() {
every { notificationPrefs.setLowBatteryEnabled(any()) } returns Unit
useCase.setLowBatteryEnabled(true)
verify { notificationPrefs.setLowBatteryEnabled(true) }
}
}

View File

@@ -1,48 +0,0 @@
/*
* 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.domain.usecase.settings
import org.meshtastic.core.testing.FakeAnalyticsPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ToggleAnalyticsUseCaseTest {
private lateinit var analyticsPrefs: FakeAnalyticsPrefs
private lateinit var useCase: ToggleAnalyticsUseCase
@BeforeTest
fun setUp() {
analyticsPrefs = FakeAnalyticsPrefs()
useCase = ToggleAnalyticsUseCase(analyticsPrefs)
}
@Test
fun `invoke toggles from false to true`() {
analyticsPrefs.setAnalyticsAllowed(false)
useCase()
assertEquals(true, analyticsPrefs.analyticsAllowed.value)
}
@Test
fun `invoke toggles from true to false`() {
analyticsPrefs.setAnalyticsAllowed(true)
useCase()
assertEquals(false, analyticsPrefs.analyticsAllowed.value)
}
}

View File

@@ -1,48 +0,0 @@
/*
* 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.domain.usecase.settings
import org.meshtastic.core.testing.FakeHomoglyphPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ToggleHomoglyphEncodingUseCaseTest {
private lateinit var homoglyphPrefs: FakeHomoglyphPrefs
private lateinit var useCase: ToggleHomoglyphEncodingUseCase
@BeforeTest
fun setUp() {
homoglyphPrefs = FakeHomoglyphPrefs()
useCase = ToggleHomoglyphEncodingUseCase(homoglyphPrefs)
}
@Test
fun `invoke toggles from false to true`() {
homoglyphPrefs.setHomoglyphEncodingEnabled(false)
useCase()
assertEquals(true, homoglyphPrefs.homoglyphEncodingEnabled.value)
}
@Test
fun `invoke toggles from true to false`() {
homoglyphPrefs.setHomoglyphEncodingEnabled(true)
useCase()
assertEquals(false, homoglyphPrefs.homoglyphEncodingEnabled.value)
}
}

View File

@@ -14,7 +14,7 @@ Models in this module use the `CommonParcelable` and `CommonParcelize` abstracti
- **`Channel`**: Represents a mesh channel configuration.
## Usage
This module is a core dependency of `core:api` and most feature modules.
This module is a core dependency of most feature modules.
```kotlin
// In commonMain

View File

@@ -18,7 +18,6 @@
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.kotlin.parcelize)
id("meshtastic.kmp.jvm.android")
id("meshtastic.publishing")
}

View File

@@ -0,0 +1,117 @@
/*
* 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.model
/**
* Device configuration and control operations.
*
* Mirrors the SDK's `AdminApi` interface — local and remote configuration, channel management, owner identity, device
* lifecycle commands, and batch edit sessions. When the SDK is adopted, this interface becomes the adapter boundary:
* implementations delegate to `RadioClient.admin`.
*
* @see RadioController which extends this interface for backward compatibility
*/
@Suppress("TooManyFunctions")
interface AdminController {
// ── Local configuration ─────────────────────────────────────────────────
/**
* Updates the local radio configuration.
*
* Fire-and-forget by design: the device is the source of truth. Local persistence is an optimistic cache that will
* self-heal on next config refresh.
*/
suspend fun setLocalConfig(config: org.meshtastic.proto.Config)
/** Updates a local radio channel. Same fire-and-forget contract as [setLocalConfig]. */
suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel)
// ── Remote configuration ────────────────────────────────────────────────
/** Updates the owner (user info) on a remote node. */
suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int)
/** Updates the general configuration on a remote node. */
suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int)
/** Updates a module configuration on a remote node. */
suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int)
/** Updates a channel configuration on a remote node. */
suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int)
/** Sets a fixed position on a remote node. */
suspend fun setFixedPosition(destNum: Int, position: Position)
/** Updates the notification ringtone on a remote node. */
suspend fun setRingtone(destNum: Int, ringtone: String)
/** Updates the canned messages configuration on a remote node. */
suspend fun setCannedMessages(destNum: Int, messages: String)
// ── Remote queries ──────────────────────────────────────────────────────
/** Requests the current owner (user info) from a remote node. */
suspend fun getOwner(destNum: Int, packetId: Int)
/** Requests a specific configuration section from a remote node. */
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
/** Requests a module configuration section from a remote node. */
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
/** Requests a specific channel configuration from a remote node. */
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
/** Requests the current ringtone from a remote node. */
suspend fun getRingtone(destNum: Int, packetId: Int)
/** Requests the current canned messages from a remote node. */
suspend fun getCannedMessages(destNum: Int, packetId: Int)
/** Requests the hardware connection status from a remote node. */
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
// ── Device lifecycle ────────────────────────────────────────────────────
/** Commands a node to reboot. */
suspend fun reboot(destNum: Int, packetId: Int)
/** Commands a node to reboot into DFU mode. */
suspend fun rebootToDfu(nodeNum: Int)
/** Initiates an OTA reboot request. */
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
/** Commands a node to shut down. */
suspend fun shutdown(destNum: Int, packetId: Int)
/** Performs a factory reset on a node. */
suspend fun factoryReset(destNum: Int, packetId: Int)
/** Resets the NodeDB on a node. */
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
// ── Batch edit ──────────────────────────────────────────────────────────
/** Signals the start of a batch configuration session. */
suspend fun beginEditSettings(destNum: Int)
/** Commits all pending configuration changes in a batch session. */
suspend fun commitEditSettings(destNum: Int)
}

View File

@@ -16,10 +16,6 @@
*/
package org.meshtastic.core.model
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
@CommonParcelize
data class Contact(
val contactKey: String,
val shortName: String,
@@ -31,7 +27,7 @@ data class Contact(
val isMuted: Boolean,
val isUnmessageable: Boolean,
val nodeColors: Pair<Int, Int>? = null,
) : CommonParcelable
)
data class ContactSettings(
val contactKey: String,

View File

@@ -16,25 +16,16 @@
*/
package org.meshtastic.core.model
import co.touchlab.kermit.Logger
import kotlinx.serialization.Serializable
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonIgnoredOnParcel
import org.meshtastic.core.common.util.CommonParcel
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
import org.meshtastic.core.common.util.CommonTypeParceler
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Waypoint
@CommonParcelize
enum class MessageStatus : CommonParcelable {
enum class MessageStatus {
UNKNOWN, // Not set for this message
RECEIVED, // Came in from the mesh
QUEUED, // Waiting to send to the mesh as soon as we connect to the device
@@ -45,17 +36,14 @@ enum class MessageStatus : CommonParcelable {
ERROR, // We received back a nak, message not delivered
}
/** A parcelable version of the protobuf MeshPacket + Data subpacket. */
/** A data class version of the protobuf MeshPacket + Data subpacket. */
@Serializable
@CommonParcelize
data class DataPacket(
var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast
@Serializable(with = ByteStringSerializer::class)
@CommonTypeParceler<ByteString?, ByteStringParceler>
var bytes: ByteString?,
var to: String? = NodeAddress.ID_BROADCAST,
@Serializable(with = ByteStringSerializer::class) var bytes: ByteString?,
// A port number for this packet
var dataType: Int,
var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost
var from: String? = NodeAddress.ID_LOCAL,
var time: Long = nowMillis, // msecs since 1970
var id: Int = 0, // 0 means unassigned
var status: MessageStatus? = MessageStatus.UNKNOWN,
@@ -70,54 +58,13 @@ data class DataPacket(
var relays: Int = 0,
var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path
var emoji: Int = 0,
@Serializable(with = ByteStringSerializer::class)
@CommonTypeParceler<ByteString?, ByteStringParceler>
var sfppHash: ByteString? = null,
@Serializable(with = ByteStringSerializer::class) var sfppHash: ByteString? = null,
/** The transport mechanism this packet arrived over (see [MeshPacket.TransportMechanism]). */
var transportMechanism: Int = 0,
) : CommonParcelable {
fun readFromParcel(parcel: CommonParcel) {
to = parcel.readString()
bytes = ByteStringParceler.create(parcel)
dataType = parcel.readInt()
from = parcel.readString()
time = parcel.readLong()
id = parcel.readInt()
// MessageStatus is a known Parcelable type (enum), so Parcelize writes it optimized:
// 1. Presence flag (Int: 1 or 0)
// 2. Content (Enum Name as String)
status =
if (parcel.readInt() != 0) {
val name = parcel.readString()
try {
if (name != null) MessageStatus.valueOf(name) else MessageStatus.UNKNOWN
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Unknown MessageStatus: $name" }
MessageStatus.UNKNOWN
}
} else {
null
}
hopLimit = parcel.readInt()
channel = parcel.readInt()
wantAck = (parcel.readInt() != 0)
hopStart = parcel.readInt()
snr = parcel.readFloat()
rssi = parcel.readInt()
replyId = if (parcel.readInt() == 0) null else parcel.readInt()
relayNode = if (parcel.readInt() == 0) null else parcel.readInt()
relays = parcel.readInt()
viaMqtt = (parcel.readInt() != 0)
emoji = parcel.readInt()
sfppHash = ByteStringParceler.create(parcel)
transportMechanism = parcel.readInt()
}
) {
/** If there was an error with this message, this string describes what was wrong. */
@CommonIgnoredOnParcel var errorMessage: String? = null
var errorMessage: String? = null
/** Syntactic sugar to make it easy to create text messages */
constructor(
@@ -176,24 +123,5 @@ data class DataPacket(
val hopsAway: Int
get() = if (hopStart == 0 || (hopLimit > hopStart)) -1 else hopStart - hopLimit
companion object {
// Special node IDs that can be used for sending messages
/** the Node ID for broadcast destinations */
const val ID_BROADCAST = "^all"
/** The Node ID for the local node - used for from when sender doesn't know our local node ID */
const val ID_LOCAL = "^local"
// special broadcast address
const val NODENUM_BROADCAST = (0xffffffff).toInt()
// Public-key cryptography (PKC) channel index
const val PKC_CHANNEL_INDEX = 8
fun nodeNumToDefaultId(n: Int): String = formatString("!%08x", n)
@Suppress("MagicNumber")
fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()
}
companion object
}

View File

@@ -0,0 +1,46 @@
/*
* 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.model
import org.meshtastic.core.common.util.nowSeconds
data class DeviceMetrics(
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val batteryLevel: Int = 0,
val voltage: Float,
val channelUtilization: Float,
val airUtilTx: Float,
val uptimeSeconds: Int,
) {
companion object {
@Suppress("MagicNumber")
fun currentTime() = nowSeconds.toInt()
}
/** Create our model object from a protobuf. */
constructor(
p: org.meshtastic.proto.DeviceMetrics,
telemetryTime: Int = currentTime(),
) : this(
telemetryTime,
p.battery_level ?: 0,
p.voltage ?: 0f,
p.channel_utilization ?: 0f,
p.air_util_tx ?: 0f,
p.uptime_seconds ?: 0,
)
}

View File

@@ -0,0 +1,55 @@
/*
* 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.model
import org.meshtastic.core.common.util.nowSeconds
data class EnvironmentMetrics(
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val temperature: Float?,
val relativeHumidity: Float?,
val soilTemperature: Float?,
val soilMoisture: Int?,
val barometricPressure: Float?,
val gasResistance: Float?,
val voltage: Float?,
val current: Float?,
val iaq: Int?,
val lux: Float? = null,
val uvLux: Float? = null,
) {
@Suppress("MagicNumber")
companion object {
fun currentTime() = nowSeconds.toInt()
fun fromTelemetryProto(proto: org.meshtastic.proto.EnvironmentMetrics, time: Int): EnvironmentMetrics =
EnvironmentMetrics(
temperature = proto.temperature?.takeIf { !it.isNaN() },
relativeHumidity = proto.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f },
soilTemperature = proto.soil_temperature?.takeIf { !it.isNaN() },
soilMoisture = proto.soil_moisture?.takeIf { it != Int.MIN_VALUE },
barometricPressure = proto.barometric_pressure?.takeIf { !it.isNaN() },
gasResistance = proto.gas_resistance?.takeIf { !it.isNaN() },
voltage = proto.voltage?.takeIf { !it.isNaN() },
current = proto.current?.takeIf { !it.isNaN() },
iaq = proto.iaq?.takeIf { it != Int.MIN_VALUE },
lux = proto.lux?.takeIf { !it.isNaN() },
uvLux = proto.uv_lux?.takeIf { !it.isNaN() },
time = time,
)
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.model
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.proto.HardwareModel
data class MeshUser(
val id: String,
val longName: String,
val shortName: String,
val hwModel: HardwareModel,
val isLicensed: Boolean = false,
val role: Int = 0,
) {
override fun toString(): String = "MeshUser(id=${id.anonymize}, " +
"longName=${longName.anonymize}, " +
"shortName=${shortName.anonymize}, " +
"hwModel=$hwModelString, " +
"isLicensed=$isLicensed, " +
"role=$role)"
/** Create our model object from a protobuf. */
constructor(
p: org.meshtastic.proto.User,
) : this(p.id, p.long_name, p.short_name, p.hw_model, p.is_licensed, p.role.value)
/**
* a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot or null
* if unset
*/
val hwModelString: String?
get() =
if (hwModel == HardwareModel.UNSET) {
null
} else {
hwModel.name.replace('_', '-').replace('p', '.').lowercase()
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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.model
/**
* Messaging operations — sending data packets, reactions, and shared contacts.
*
* Mirrors the SDK's send/messaging surface. When the SDK is adopted, implementations delegate to `RadioClient.send()` /
* `RadioClient.sendText()`.
*
* @see RadioController which extends this interface for backward compatibility
*/
interface MessagingController {
/** Sends a data packet to the mesh. */
suspend fun sendMessage(packet: DataPacket)
/** Sends an emoji reaction to a message. Awaits local DB persistence. */
suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String)
/** Imports a shared contact into the firmware's NodeDB. */
suspend fun importContact(contact: org.meshtastic.proto.SharedContact)
/**
* Sends our shared contact information (identity and public key) to the firmware's NodeDB.
*
* @param nodeNum The destination node number.
* @return `true` if the radio accepted the contact, `false` on timeout or failure.
*/
suspend fun sendSharedContact(nodeNum: Int): Boolean
}

View File

@@ -16,11 +16,7 @@
*/
package org.meshtastic.core.model
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
// MyNodeInfo sent via special protobuf from radio
@CommonParcelize
data class MyNodeInfo(
val myNodeNum: Int,
val hasGPS: Boolean,
@@ -37,7 +33,7 @@ data class MyNodeInfo(
val airUtilTx: Float,
val deviceId: String?,
val pioEnv: String? = null,
) : CommonParcelable {
) {
/** A human readable description of the software/hardware version */
val firmwareString: String
get() = "$model $firmwareVersion"

View File

@@ -209,7 +209,7 @@ data class Node(
/** Creates a fallback [Node] when the node is not found in the database. */
fun createFallback(nodeNum: Int, fallbackNamePrefix: String): Node {
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
val userId = NodeAddress.numToDefaultId(nodeNum)
val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH)
val longName = "$fallbackNamePrefix $safeUserId"
val defaultUser =

View File

@@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model
import org.meshtastic.core.common.util.formatString
import kotlin.jvm.JvmInline
/**
* Type-safe representation of a mesh node address.
*
* Replaces stringly-typed node addressing (`"^all"`, `"^local"`, `"!hexid"`) with exhaustive sealed dispatch, enabling
* compile-time verification of address handling.
*/
sealed class NodeAddress {
/** Broadcast to all nodes in the mesh. */
data object Broadcast : NodeAddress()
/** The local node (used as `from` when the sender's ID is unknown). */
data object Local : NodeAddress()
/** Address by numeric node number (the canonical mesh-level identifier). */
data class ByNum(val num: Int) : NodeAddress()
/** Address by hex string ID (e.g. `"!a1b2c3d4"`). */
data class ById(val id: String) : NodeAddress()
/** Convert back to the legacy string representation used in [DataPacket]. */
fun toIdString(): String = when (this) {
Broadcast -> ID_BROADCAST
Local -> ID_LOCAL
is ByNum -> numToDefaultId(num)
is ById -> id
}
/** Build a [ContactKey] for this address on the given [channel]. */
fun toContactKey(channel: Int): ContactKey = ContactKey("$channel${toIdString()}")
companion object {
/** The broadcast address string `"^all"`. */
const val ID_BROADCAST = "^all"
/** The local node address string `"^local"`. */
const val ID_LOCAL = "^local"
/** The broadcast node number (`0xFFFFFFFF`). */
@Suppress("MagicNumber")
const val NODENUM_BROADCAST = (0xffffffff).toInt()
/** Public-key cryptography (PKC) channel index. */
const val PKC_CHANNEL_INDEX = 8
private const val NODE_ID_PREFIX = "!"
private const val HEX_RADIX = 16
/** Parse a legacy string address into a typed [NodeAddress]. */
fun fromString(id: String?): NodeAddress = when {
id == null || id == ID_BROADCAST -> Broadcast
id == ID_LOCAL -> Local
id.startsWith(NODE_ID_PREFIX) -> {
val num = idToNum(id.removePrefix(NODE_ID_PREFIX))
if (num != null) ByNum(num) else ById(id)
}
else -> ById(id)
}
/** Convert a node number to its canonical hex string ID (e.g. `"!a1b2c3d4"`). */
fun numToDefaultId(n: Int): String = formatString("!%08x", n)
/** Parse a hex node ID string (with or without `!` prefix) to its integer value, or null. */
@Suppress("MagicNumber")
fun idToNum(id: String?): Int? =
runCatching { id?.removePrefix(NODE_ID_PREFIX)?.toLong(HEX_RADIX)?.toInt() }.getOrNull()
}
}
/**
* Type-safe wrapper for contact key strings (channel index + node address).
*
* Contact keys are persisted as strings in the format `"<channel><nodeId>"` (e.g. `"0^all"`, `"1!a1b2c3d4"`).
*/
@JvmInline
value class ContactKey(val value: String) {
/** The channel index (first character). */
val channel: Int
get() = value[0].digitToInt()
/** The node address portion (everything after the channel digit). */
val addressString: String
get() = value.substring(1)
/** Parsed [NodeAddress] for the contact. */
val address: NodeAddress
get() = NodeAddress.fromString(addressString)
companion object {
/** Create a broadcast contact key for the given channel. */
fun broadcast(channel: Int = 0): ContactKey = NodeAddress.Broadcast.toContactKey(channel)
}
}
/** Type-safe interpretation of [DataPacket.to]. */
val DataPacket.destination: NodeAddress
get() = NodeAddress.fromString(to)
/** Type-safe interpretation of [DataPacket.from]. */
val DataPacket.source: NodeAddress
get() = NodeAddress.fromString(from)
/** Checks whether this packet originated from the local device. */
fun DataPacket.isFromLocal(myNodeNum: Int? = null): Boolean {
val src = source
return src is NodeAddress.Local || (myNodeNum != null && src is NodeAddress.ByNum && src.num == myNodeNum)
}
/** Checks whether this packet is addressed to the broadcast channel. */
val DataPacket.isBroadcast: Boolean
get() = destination is NodeAddress.Broadcast

View File

@@ -0,0 +1,40 @@
/*
* 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.model
/**
* Node management operations — favorite, ignore, mute, and remove nodes.
*
* Mirrors the node management subset of the SDK's `AdminApi` (setFavorite, setIgnored, toggleMuted). When the SDK is
* adopted, implementations delegate to `RadioClient.admin.setFavorite(NodeId, Boolean)` etc.
*
* @see RadioController which extends this interface for backward compatibility
*/
interface NodeController {
/** Toggles the favorite status of a node on the radio. */
suspend fun favoriteNode(nodeNum: Int)
/** Toggles the ignore status of a node on the radio. */
suspend fun ignoreNode(nodeNum: Int)
/** Toggles the mute status of a node on the radio. */
suspend fun muteNode(nodeNum: Int)
/** Removes a node from the mesh by its node number. */
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
}

View File

@@ -1,275 +0,0 @@
/*
* 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.model
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
//
// model objects that directly map to the corresponding protobufs
//
@CommonParcelize
data class MeshUser(
val id: String,
val longName: String,
val shortName: String,
val hwModel: HardwareModel,
val isLicensed: Boolean = false,
val role: Int = 0,
) : CommonParcelable {
override fun toString(): String = "MeshUser(id=${id.anonymize}, " +
"longName=${longName.anonymize}, " +
"shortName=${shortName.anonymize}, " +
"hwModel=$hwModelString, " +
"isLicensed=$isLicensed, " +
"role=$role)"
/** Create our model object from a protobuf. */
constructor(
p: org.meshtastic.proto.User,
) : this(p.id, p.long_name, p.short_name, p.hw_model, p.is_licensed, p.role.value)
/**
* a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot or null
* if unset
*/
val hwModelString: String?
get() =
if (hwModel == HardwareModel.UNSET) {
null
} else {
hwModel.name.replace('_', '-').replace('p', '.').lowercase()
}
}
@CommonParcelize
data class Position(
val latitude: Double,
val longitude: Double,
val altitude: Int,
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val satellitesInView: Int = 0,
val groundSpeed: Int = 0,
val groundTrack: Int = 0, // "heading"
val precisionBits: Int = 0,
) : CommonParcelable {
@Suppress("MagicNumber")
companion object {
// / Convert to a double representation of degrees
fun degD(i: Int) = i * 1e-7
fun degI(d: Double) = (d * 1e7).toInt()
fun currentTime() = nowSeconds.toInt()
}
/**
* Create our model object from a protobuf. If time is unspecified in the protobuf, the provided default time will
* be used.
*/
constructor(
position: org.meshtastic.proto.Position,
defaultTime: Int = currentTime(),
) : this(
// We prefer the int version of lat/lon but if not available use the depreciated legacy version
degD(position.latitude_i ?: 0),
degD(position.longitude_i ?: 0),
position.altitude ?: 0,
if (position.time != 0) position.time else defaultTime,
position.sats_in_view,
position.ground_speed ?: 0,
position.ground_track ?: 0,
position.precision_bits,
)
// / @return distance in meters to some other node (or null if unknown)
fun distance(o: Position) = latLongToMeter(latitude, longitude, o.latitude, o.longitude)
// / @return bearing to the other position in degrees
fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude)
// If GPS gives a crap position don't crash our app
@Suppress("MagicNumber")
fun isValid(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
override fun toString(): String =
"Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)"
}
@CommonParcelize
data class DeviceMetrics(
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val batteryLevel: Int = 0,
val voltage: Float,
val channelUtilization: Float,
val airUtilTx: Float,
val uptimeSeconds: Int,
) : CommonParcelable {
companion object {
@Suppress("MagicNumber")
fun currentTime() = nowSeconds.toInt()
}
/** Create our model object from a protobuf. */
constructor(
p: org.meshtastic.proto.DeviceMetrics,
telemetryTime: Int = currentTime(),
) : this(
telemetryTime,
p.battery_level ?: 0,
p.voltage ?: 0f,
p.channel_utilization ?: 0f,
p.air_util_tx ?: 0f,
p.uptime_seconds ?: 0,
)
}
@CommonParcelize
data class EnvironmentMetrics(
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val temperature: Float?,
val relativeHumidity: Float?,
val soilTemperature: Float?,
val soilMoisture: Int?,
val barometricPressure: Float?,
val gasResistance: Float?,
val voltage: Float?,
val current: Float?,
val iaq: Int?,
val lux: Float? = null,
val uvLux: Float? = null,
) : CommonParcelable {
@Suppress("MagicNumber")
companion object {
fun currentTime() = nowSeconds.toInt()
fun fromTelemetryProto(proto: org.meshtastic.proto.EnvironmentMetrics, time: Int): EnvironmentMetrics =
EnvironmentMetrics(
temperature = proto.temperature?.takeIf { !it.isNaN() },
relativeHumidity = proto.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f },
soilTemperature = proto.soil_temperature?.takeIf { !it.isNaN() },
soilMoisture = proto.soil_moisture?.takeIf { it != Int.MIN_VALUE },
barometricPressure = proto.barometric_pressure?.takeIf { !it.isNaN() },
gasResistance = proto.gas_resistance?.takeIf { !it.isNaN() },
voltage = proto.voltage?.takeIf { !it.isNaN() },
current = proto.current?.takeIf { !it.isNaN() },
iaq = proto.iaq?.takeIf { it != Int.MIN_VALUE },
lux = proto.lux?.takeIf { !it.isNaN() },
uvLux = proto.uv_lux?.takeIf { !it.isNaN() },
time = time,
)
}
}
@CommonParcelize
data class NodeInfo(
val num: Int, // This is immutable, and used as a key
var user: MeshUser? = null,
var position: Position? = null,
var snr: Float = Float.MAX_VALUE,
var rssi: Int = Int.MAX_VALUE,
var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
var deviceMetrics: DeviceMetrics? = null,
var channel: Int = 0,
var environmentMetrics: EnvironmentMetrics? = null,
var hopsAway: Int = 0,
var nodeStatus: String? = null,
) : CommonParcelable {
@Suppress("MagicNumber")
val colors: Pair<Int, Int>
get() { // returns foreground and background @ColorInt for each 'num'
val r = (num and 0xFF0000) shr 16
val g = (num and 0x00FF00) shr 8
val b = num and 0x0000FF
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
return foreground to background
}
val batteryLevel
get() = deviceMetrics?.batteryLevel
val voltage
get() = deviceMetrics?.voltage
@Suppress("ImplicitDefaultLocale")
val batteryStr
get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
/** true if the device was heard from recently */
val isOnline: Boolean
get() {
return lastHeard > onlineTimeThreshold()
}
// / return the position if it is valid, else null
val validPosition: Position?
get() {
return position?.takeIf { it.isValid() }
}
// / @return distance in meters to some other node (or null if unknown)
fun distance(o: NodeInfo?): Int? {
val p = validPosition
val op = o?.validPosition
return if (p != null && op != null) p.distance(op).toInt() else null
}
// / @return bearing to the other position in degrees
fun bearing(o: NodeInfo?): Int? {
val p = validPosition
val op = o?.validPosition
return if (p != null && op != null) p.bearing(op).toInt() else null
}
// / @return a nice human readable string for the distance, or null for unknown
@Suppress("MagicNumber")
fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist ->
when {
dist == 0 -> null
// same point
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist < 1000 -> "$dist m"
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist >= 1000 ->
"${(dist / 100).toDouble() / 10.0} km"
prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist < 1609 ->
"${(dist.toDouble() * 3.281).toInt()} ft"
prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist >= 1609 ->
"${(dist / 160.9).toInt() / 10.0} mi"
else -> null
}
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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.model
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.anonymize
data class Position(
val latitude: Double,
val longitude: Double,
val altitude: Int,
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val satellitesInView: Int = 0,
val groundSpeed: Int = 0,
val groundTrack: Int = 0, // "heading"
val precisionBits: Int = 0,
) {
@Suppress("MagicNumber")
companion object {
fun degD(i: Int) = i * 1e-7
fun degI(d: Double) = (d * 1e7).toInt()
fun currentTime() = nowSeconds.toInt()
}
/**
* Create our model object from a protobuf. If time is unspecified in the protobuf, the provided default time will
* be used.
*/
constructor(
position: org.meshtastic.proto.Position,
defaultTime: Int = currentTime(),
) : this(
degD(position.latitude_i ?: 0),
degD(position.longitude_i ?: 0),
position.altitude ?: 0,
if (position.time != 0) position.time else defaultTime,
position.sats_in_view,
position.ground_speed ?: 0,
position.ground_track ?: 0,
position.precision_bits,
)
/** @return distance in meters to some other position */
fun distance(o: Position) = latLongToMeter(latitude, longitude, o.latitude, o.longitude)
/** @return bearing to the other position in degrees */
fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude)
@Suppress("MagicNumber")
fun isValid(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
override fun toString(): String =
"Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)"
}

View File

@@ -22,12 +22,24 @@ import org.meshtastic.proto.ClientNotification
/**
* Central interface for controlling the radio and mesh network.
*
* This component provides an abstraction over the underlying communication transport (e.g., BLE, Serial, TCP) and the
* low-level mesh protocols. It allows feature modules to interact with the mesh without needing to know about
* platform-specific service details or AIDL interfaces.
* This is a composite interface that extends the focused sub-interfaces below. Feature modules that need the full
* surface inject [RadioController]; modules that need only a subset can inject the narrower interface for better
* testability and clearer dependency intent.
*
* **Sub-interfaces (mirrors SDK's layered API design):**
* - [AdminController] — config, channels, owner, device lifecycle (→ SDK `AdminApi`)
* - [MessagingController] — send packets, reactions, contacts (→ SDK `RadioClient.send*`)
* - [NodeController] — favorite, ignore, mute, remove nodes (→ SDK `AdminApi` node ops)
* - [RequestController] — telemetry, traceroute, position queries (→ SDK `TelemetryApi` / `RoutingApi`)
*
* When migrating to the SDK, each sub-interface becomes a thin adapter over the corresponding SDK API. The composite
* [RadioController] can then be deprecated and consumers migrated to the narrower interfaces one at a time.
*/
@Suppress("TooManyFunctions")
interface RadioController {
interface RadioController :
AdminController,
MessagingController,
NodeController,
RequestController {
/**
* Canonical app-level connection state, delegated from [ServiceRepository][connectionState].
*
@@ -47,279 +59,9 @@ interface RadioController {
*/
val clientNotification: StateFlow<ClientNotification?>
/**
* Sends a data packet to the mesh.
*
* @param packet The [DataPacket] containing the payload and routing information.
*/
suspend fun sendMessage(packet: DataPacket)
/** Clears the current [clientNotification]. */
fun clearClientNotification()
/**
* Toggles the favorite status of a node on the radio.
*
* @param nodeNum The node number to favorite/unfavorite.
*/
suspend fun favoriteNode(nodeNum: Int)
/**
* Sends our shared contact information (identity and public key) to the firmware's NodeDB.
*
* This ensures the firmware has the correct public key for the destination node before a PKI-encrypted direct
* message is sent. The method suspends until the radio acknowledges the admin packet.
*
* @param nodeNum The destination node number.
* @return `true` if the radio accepted the contact, `false` on timeout or failure.
*/
suspend fun sendSharedContact(nodeNum: Int): Boolean
/**
* Updates the local radio configuration.
*
* @param config The new configuration [org.meshtastic.proto.Config].
*/
suspend fun setLocalConfig(config: org.meshtastic.proto.Config)
/**
* Updates a local radio channel.
*
* @param channel The channel configuration [org.meshtastic.proto.Channel].
*/
suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel)
/**
* Updates the owner (user info) on a remote node.
*
* @param destNum The destination node number.
* @param user The new user info [org.meshtastic.proto.User].
* @param packetId The request packet ID.
*/
suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int)
/**
* Updates the general configuration on a remote node.
*
* @param destNum The destination node number.
* @param config The new configuration [org.meshtastic.proto.Config].
* @param packetId The request packet ID.
*/
suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int)
/**
* Updates a module configuration on a remote node.
*
* @param destNum The destination node number.
* @param config The new module configuration [org.meshtastic.proto.ModuleConfig].
* @param packetId The request packet ID.
*/
suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int)
/**
* Updates a channel configuration on a remote node.
*
* @param destNum The destination node number.
* @param channel The new channel configuration [org.meshtastic.proto.Channel].
* @param packetId The request packet ID.
*/
suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int)
/**
* Sets a fixed position on a remote node.
*
* @param destNum The destination node number.
* @param position The position to set.
*/
suspend fun setFixedPosition(destNum: Int, position: Position)
/**
* Updates the notification ringtone on a remote node.
*
* @param destNum The destination node number.
* @param ringtone The name/ID of the ringtone.
*/
suspend fun setRingtone(destNum: Int, ringtone: String)
/**
* Updates the canned messages configuration on a remote node.
*
* @param destNum The destination node number.
* @param messages The canned messages string.
*/
suspend fun setCannedMessages(destNum: Int, messages: String)
/**
* Requests the current owner (user info) from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getOwner(destNum: Int, packetId: Int)
/**
* Requests a specific configuration section from a remote node.
*
* @param destNum The remote node number.
* @param configType The numeric type of the configuration section.
* @param packetId The request packet ID.
*/
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
/**
* Requests a module configuration section from a remote node.
*
* @param destNum The remote node number.
* @param moduleConfigType The numeric type of the module configuration section.
* @param packetId The request packet ID.
*/
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
/**
* Requests a specific channel configuration from a remote node.
*
* @param destNum The remote node number.
* @param index The channel index.
* @param packetId The request packet ID.
*/
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
/**
* Requests the current ringtone from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getRingtone(destNum: Int, packetId: Int)
/**
* Requests the current canned messages from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getCannedMessages(destNum: Int, packetId: Int)
/**
* Requests the hardware connection status from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
/**
* Commands a node to reboot.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
*/
suspend fun reboot(destNum: Int, packetId: Int)
/**
* Commands a node to reboot into DFU (Device Firmware Update) mode.
*
* @param nodeNum The target node number.
*/
suspend fun rebootToDfu(nodeNum: Int)
/**
* Initiates an Over-The-Air (OTA) reboot request.
*
* @param requestId The request ID.
* @param destNum The target node number.
* @param mode The OTA mode.
* @param hash Optional hash for verification.
*/
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
/**
* Commands a node to shut down.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
*/
suspend fun shutdown(destNum: Int, packetId: Int)
/**
* Performs a factory reset on a node.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
*/
suspend fun factoryReset(destNum: Int, packetId: Int)
/**
* Resets the NodeDB on a node.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
* @param preserveFavorites Whether to keep favorite nodes in the database.
*/
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
/**
* Removes a node from the mesh by its node number.
*
* @param packetId The request packet ID.
* @param nodeNum The node number to remove.
*/
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
/**
* Requests the current GPS position from a remote node.
*
* @param destNum The target node number.
* @param currentPosition Our current position to provide in the request.
*/
suspend fun requestPosition(destNum: Int, currentPosition: Position)
/**
* Requests detailed user info from a remote node.
*
* @param destNum The target node number.
*/
suspend fun requestUserInfo(destNum: Int)
/**
* Initiates a traceroute request to a remote node.
*
* @param requestId The request ID.
* @param destNum The destination node number.
*/
suspend fun requestTraceroute(requestId: Int, destNum: Int)
/**
* Requests telemetry data from a remote node.
*
* @param requestId The request ID.
* @param destNum The destination node number.
* @param typeValue The numeric type of telemetry requested.
*/
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
/**
* Requests neighbor information (detected nodes) from a remote node.
*
* @param requestId The request ID.
* @param destNum The destination node number.
*/
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
/**
* Signals the start of a batch configuration session.
*
* @param destNum The target node number.
*/
suspend fun beginEditSettings(destNum: Int)
/**
* Commits all pending configuration changes in a batch session.
*
* @param destNum The target node number.
*/
suspend fun commitEditSettings(destNum: Int)
/**
* Generates a unique packet ID for a new request.
*

View File

@@ -0,0 +1,46 @@
/*
* 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.model
/**
* Mesh request operations — position, traceroute, telemetry, user info, and metadata queries.
*
* These are "pull" operations that request data from remote nodes. When the SDK is adopted, implementations delegate to
* `RadioClient.telemetry` and `RadioClient.routing` sub-APIs.
*
* @see RadioController which extends this interface for backward compatibility
*/
interface RequestController {
/** Requests device metadata from a remote node. */
suspend fun refreshMetadata(destNum: Int)
/** Requests the current GPS position from a remote node. */
suspend fun requestPosition(destNum: Int, currentPosition: Position)
/** Requests detailed user info from a remote node. */
suspend fun requestUserInfo(destNum: Int)
/** Initiates a traceroute request to a remote node. */
suspend fun requestTraceroute(requestId: Int, destNum: Int)
/** Requests telemetry data from a remote node. */
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
/** Requests neighbor information (detected nodes) from a remote node. */
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
}

View File

@@ -1,49 +0,0 @@
/*
* 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.model.service
import kotlinx.coroutines.CompletableDeferred
import org.meshtastic.core.model.Node
import org.meshtastic.proto.SharedContact
sealed class ServiceAction {
data class GetDeviceMetadata(val destNum: Int) : ServiceAction()
data class Favorite(val node: Node) : ServiceAction()
data class Ignore(val node: Node) : ServiceAction()
data class Mute(val node: Node) : ServiceAction()
data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
data class ImportContact(val contact: SharedContact) : ServiceAction()
/**
* Sends a shared contact (identity + public key) to the firmware's NodeDB.
*
* The [result] deferred is completed with `true` when the radio acknowledges the admin packet, or `false` on
* timeout/failure. Callers that need to guarantee the contact is stored before sending a subsequent DM should
* `await()` this deferred.
*
* Not a data class: [result] is a [CompletableDeferred] with identity-based equality that would break data class
* equals/hashCode/copy semantics.
*/
class SendContact(val contact: SharedContact) : ServiceAction() {
val result: CompletableDeferred<Boolean> = CompletableDeferred()
}
}

View File

@@ -23,8 +23,6 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonParcel
import org.meshtastic.core.common.util.CommonParceler
/** Serializer for Okio [ByteString] using kotlinx.serialization */
object ByteStringSerializer : KSerializer<ByteString> {
@@ -38,12 +36,3 @@ object ByteStringSerializer : KSerializer<ByteString> {
override fun deserialize(decoder: Decoder): ByteString = byteArraySerializer.deserialize(decoder).toByteString()
}
/** Parceler for Okio [ByteString] for Android Parcelable support */
object ByteStringParceler : CommonParceler<ByteString?> {
override fun create(parcel: CommonParcel): ByteString? = parcel.createByteArray()?.toByteString()
override fun ByteString?.write(parcel: CommonParcel, flags: Int) {
parcel.writeByteArray(this?.toByteArray())
}
}

View File

@@ -20,6 +20,7 @@ package org.meshtastic.core.model.util
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.proto.MeshPacket
/**
@@ -40,7 +41,7 @@ open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
dataType = decoded.portnum.value,
bytes = decoded.payload.toByteArray().toByteString(),
hopLimit = packet.hop_limit,
channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
channel = if (packet.pki_encrypted == true) NodeAddress.PKC_CHANNEL_INDEX else packet.channel,
wantAck = packet.want_ack == true,
hopStart = packet.hop_start,
snr = packet.rx_snr,

View File

@@ -59,7 +59,7 @@ fun <T : Message<T, *>> ProtoAdapter<T>.decodeOrNull(bytes: ByteArray?, logger:
* ```
* val data = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = bytes)
* if (!Data.ADAPTER.isWithinSizeLimit(data, MAX_PAYLOAD)) {
* throw RemoteException("Payload too large")
* error("Payload too large")
* }
* ```
*

View File

@@ -38,8 +38,8 @@ class DataPacketTest {
@Test
fun nodeNumToDefaultId_formatsHexWithPrefix() {
assertEquals("!1234abcd", DataPacket.nodeNumToDefaultId(0x1234ABCD))
assertEquals("!ffffffff", DataPacket.nodeNumToDefaultId(DataPacket.NODENUM_BROADCAST))
assertEquals("!1234abcd", NodeAddress.numToDefaultId(0x1234ABCD))
assertEquals("!ffffffff", NodeAddress.numToDefaultId(NodeAddress.NODENUM_BROADCAST))
}
@Test

View File

@@ -17,8 +17,8 @@
package org.meshtastic.core.model.util
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetrics
@@ -104,7 +104,7 @@ class MeshDataMapperTest {
val mapped = mapper.toDataPacket(packet)
assertNotNull(mapped)
assertEquals(DataPacket.PKC_CHANNEL_INDEX, mapped.channel)
assertEquals(NodeAddress.PKC_CHANNEL_INDEX, mapped.channel)
}
@Test
@@ -281,6 +281,6 @@ class MeshDataMapperTest {
}
private class TestNodeIdLookup : NodeIdLookup {
override fun toNodeID(nodeNum: Int): String = DataPacket.nodeNumToDefaultId(nodeNum)
override fun toNodeID(nodeNum: Int): String = NodeAddress.numToDefaultId(nodeNum)
}
}

View File

@@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.meshtastic.core.common.util.registerReceiverCompat
private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION"
private const val ACTION_USB_PERMISSION = "org.meshtastic.app.USB_PERMISSION"
internal fun UsbManager.requestPermission(context: Context, device: UsbDevice): Flow<Boolean> = callbackFlow {
val receiver =

View File

@@ -24,7 +24,7 @@ import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.getInitials
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportCallback
@@ -333,7 +333,7 @@ class MockRadioTransport(
num = numIn,
user =
User(
id = DataPacket.nodeNumToDefaultId(numIn),
id = NodeAddress.numToDefaultId(numIn),
long_name = "Sim ${numIn.toString(16)}",
short_name = getInitials("Sim ${numIn.toString(16)}"),
hw_model = HardwareModel.ANDROID_SIM,

View File

@@ -51,7 +51,6 @@ src/
│ ├── RadioConfigRepository.kt
│ ├── RadioInterfaceService.kt
│ ├── RadioTransportCallback.kt / RadioTransportFactory.kt
│ ├── ServiceBroadcasts.kt
│ ├── StoreForwardPacketHandler.kt
│ ├── TelemetryPacketHandler.kt
│ ├── TracerouteHandler.kt / TracerouteSnapshotRepository.kt

View File

@@ -38,10 +38,10 @@ interface CommandSender {
fun generatePacketId(): Int
/** Sends a data packet to the mesh. */
fun sendData(p: DataPacket)
suspend fun sendData(p: DataPacket)
/** Sends an admin message to a specific node. */
fun sendAdmin(
suspend fun sendAdmin(
destNum: Int,
requestId: Int = generatePacketId(),
wantResponse: Boolean = false,
@@ -64,23 +64,23 @@ interface CommandSender {
): Boolean
/** Sends our current position to the mesh. */
fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false)
suspend fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false)
/** Requests the position of a specific node. */
fun requestPosition(destNum: Int, currentPosition: Position)
suspend fun requestPosition(destNum: Int, currentPosition: Position)
/** Sets a fixed position for a node. */
fun setFixedPosition(destNum: Int, pos: Position)
suspend fun setFixedPosition(destNum: Int, pos: Position)
/** Requests user info from a specific node. */
fun requestUserInfo(destNum: Int)
suspend fun requestUserInfo(destNum: Int)
/** Requests a traceroute to a specific node. */
fun requestTraceroute(requestId: Int, destNum: Int)
suspend fun requestTraceroute(requestId: Int, destNum: Int)
/** Requests telemetry from a specific node. */
fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
/** Requests neighbor info from a specific node. */
fun requestNeighborInfo(requestId: Int, destNum: Int)
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
}

View File

@@ -28,7 +28,7 @@ interface HistoryManager {
* @param storeForwardConfig The store-and-forward module configuration.
* @param transport The transport method being used (for logging).
*/
fun requestHistoryReplay(
suspend fun requestHistoryReplay(
trigger: String,
myNodeNum: Int?,
storeForwardConfig: ModuleConfig.StoreForwardConfig?,

View File

@@ -1,119 +0,0 @@
/*
* 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.repository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
/** Interface for handling UI-triggered actions and administrative commands for the mesh. */
@Suppress("TooManyFunctions")
interface MeshActionHandler {
/** Processes a service action from the UI. */
suspend fun onServiceAction(action: ServiceAction)
/** Sets the owner of the local node. */
fun handleSetOwner(u: MeshUser, myNodeNum: Int)
/** Sends a data packet through the mesh. */
fun handleSend(p: DataPacket, myNodeNum: Int)
/** Requests the position of a remote node. */
fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int)
/** Removes a node from the database by its node number. */
fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int)
/** Sets the owner of a remote node. */
fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray)
/** Gets the owner of a remote node. */
fun handleGetRemoteOwner(id: Int, destNum: Int)
/** Sets the configuration of the local node. */
fun handleSetConfig(payload: ByteArray, myNodeNum: Int)
/** Sets the configuration of a remote node. */
fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray)
/** Gets the configuration of a remote node. */
fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int)
/** Sets the module configuration of a remote node. */
fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray)
/** Gets the module configuration of a remote node. */
fun handleGetModuleConfig(id: Int, destNum: Int, config: Int)
/** Sets the ringtone of a remote node. */
fun handleSetRingtone(destNum: Int, ringtone: String)
/** Gets the ringtone of a remote node. */
fun handleGetRingtone(id: Int, destNum: Int)
/** Sets canned messages on a remote node. */
fun handleSetCannedMessages(destNum: Int, messages: String)
/** Gets canned messages from a remote node. */
fun handleGetCannedMessages(id: Int, destNum: Int)
/** Sets a channel configuration on the local node. */
fun handleSetChannel(payload: ByteArray?, myNodeNum: Int)
/** Sets a channel configuration on a remote node. */
fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?)
/** Gets a channel configuration from a remote node. */
fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int)
/** Requests neighbor information from a remote node. */
fun handleRequestNeighborInfo(requestId: Int, destNum: Int)
/** Begins editing settings on a remote node. */
fun handleBeginEditSettings(destNum: Int)
/** Commits settings edits on a remote node. */
fun handleCommitEditSettings(destNum: Int)
/** Reboots a remote node into DFU mode. */
fun handleRebootToDfu(destNum: Int)
/** Requests telemetry from a remote node. */
fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int)
/** Requests a remote node to shut down. */
fun handleRequestShutdown(requestId: Int, destNum: Int)
/** Requests a remote node to reboot. */
fun handleRequestReboot(requestId: Int, destNum: Int)
/** Requests a remote node to reboot in OTA mode. */
fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
/** Requests a factory reset on a remote node. */
fun handleRequestFactoryReset(requestId: Int, destNum: Int)
/** Requests a node database reset on a remote node. */
fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean)
/** Gets the connection status of a remote node. */
fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int)
/** Updates the last used device address. */
fun handleUpdateLastAddress(deviceAddr: String?)
}

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