feat: service extraction (#4828)

This commit is contained in:
James Rich 2026-03-17 14:06:01 -05:00 committed by GitHub
parent 0d0bdf9172
commit 807db83f53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 309 additions and 257 deletions

View file

@ -152,7 +152,7 @@
<!-- This is the public API for doing mesh radio operations from android apps -->
<service
android:name="org.meshtastic.app.service.MeshService"
android:name="org.meshtastic.core.service.MeshService"
android:enabled="true"
android:foregroundServiceType="connectedDevice|location"
android:exported="true" tools:ignore="ExportedActivity">
@ -228,7 +228,7 @@
android:resource="@xml/device_filter" />
</activity>
<receiver android:name="org.meshtastic.app.service.BootCompleteReceiver"
<receiver android:name="org.meshtastic.core.service.BootCompleteReceiver"
android:exported="false">
<!-- handle boot events -->
<intent-filter>
@ -252,9 +252,9 @@
android:path="com.geeksville.mesh" /> -->
</intent-filter>
</receiver>
<receiver android:name="org.meshtastic.app.service.ReplyReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.app.service.MarkAsReadReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.app.service.ReactionReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.core.service.ReplyReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.core.service.MarkAsReadReceiver" android:exported="false" />
<receiver android:name="org.meshtastic.core.service.ReactionReceiver" android:exported="false" />
<receiver
android:name="org.meshtastic.app.widget.LocalStatsWidgetReceiver"

View file

@ -25,13 +25,13 @@ import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.meshtastic.app.service.MeshService
import org.meshtastic.app.service.startService
import org.meshtastic.core.common.util.SequentialJob
import org.meshtastic.core.service.AndroidServiceRepository
import org.meshtastic.core.service.BindFailedException
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.service.ServiceClient
import org.meshtastic.core.service.startService
/** A Activity-lifecycle-aware [ServiceClient] that binds [MeshService] once the Activity is started. */
@Factory

View file

@ -40,10 +40,10 @@ import org.koin.core.context.startKoin
import org.meshtastic.app.di.AppKoinModule
import org.meshtastic.app.di.module
import org.meshtastic.app.widget.LocalStatsWidgetReceiver
import org.meshtastic.app.worker.MeshLogCleanupWorker
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.service.worker.MeshLogCleanupWorker
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration

View file

@ -37,14 +37,15 @@ import org.meshtastic.core.database.di.CoreDatabaseAndroidModule
import org.meshtastic.core.database.di.CoreDatabaseModule
import org.meshtastic.core.datastore.di.CoreDatastoreAndroidModule
import org.meshtastic.core.datastore.di.CoreDatastoreModule
import org.meshtastic.core.network.di.CoreNetworkAndroidModule
import org.meshtastic.core.network.di.CoreNetworkModule
import org.meshtastic.core.network.repository.ProbeTableProvider
import org.meshtastic.core.prefs.di.CorePrefsAndroidModule
import org.meshtastic.core.prefs.di.CorePrefsModule
import org.meshtastic.core.service.di.CoreServiceAndroidModule
import org.meshtastic.core.service.di.CoreServiceModule
import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
import org.meshtastic.feature.connections.repository.ProbeTableProvider
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
import org.meshtastic.feature.intro.di.FeatureIntroModule
import org.meshtastic.feature.map.di.FeatureMapModule
@ -72,6 +73,7 @@ import org.meshtastic.feature.settings.di.FeatureSettingsModule
CoreServiceModule::class,
CoreServiceAndroidModule::class,
CoreNetworkModule::class,
CoreNetworkAndroidModule::class,
CoreUiModule::class,
FeatureNodeModule::class,
FeatureMessagingModule::class,

View file

@ -1,64 +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.app.messaging.domain.worker
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import org.koin.android.annotation.KoinWorker
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.PacketRepository
@KoinWorker
class SendMessageWorker(
context: Context,
params: WorkerParameters,
private val packetRepository: PacketRepository,
private val radioController: RadioController,
) : CoroutineWorker(context, params) {
@Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount")
override suspend fun doWork(): Result {
val packetId = inputData.getInt(KEY_PACKET_ID, 0)
if (packetId == 0) return Result.failure()
// Verify we are connected before attempting to send to avoid unnecessary Exception bubbling
if (radioController.connectionState.value != ConnectionState.Connected) {
return Result.retry()
}
val packetData =
packetRepository.getPacketByPacketId(packetId)
?: return Result.failure() // Packet no longer exists in DB? Do not retry.
return try {
radioController.sendMessage(packetData)
packetRepository.updateMessageStatus(packetData, MessageStatus.ENROUTE)
Result.success()
} catch (e: Exception) {
packetRepository.updateMessageStatus(packetData, MessageStatus.QUEUED)
Result.retry()
}
}
companion object {
const val KEY_PACKET_ID = "packet_id"
const val WORK_NAME_PREFIX = "send_message_"
}
}

View file

@ -22,6 +22,7 @@ import androidx.work.WorkManager
import androidx.work.workDataOf
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.service.worker.SendMessageWorker
/** Android implementation of [MessageQueue] that uses [WorkManager] for reliable background transmission. */
@Single

View file

@ -1,396 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import android.app.Application
import android.provider.Settings
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.util.BinaryLogFile
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.feature.connections.repository.NetworkRepository
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
/**
* Handles the bluetooth link with a mesh radio device. Does not cache any device state, just does bluetooth comms
* etc...
*
* This service is not exposed outside of this process.
*
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it
* can be stubbed out with a simulated version as needed.
*/
@Suppress("LongParameterList", "TooManyFunctions")
@Single
class AndroidRadioInterfaceService(
private val context: Application,
private val dispatchers: CoroutineDispatchers,
private val bluetoothRepository: BluetoothRepository,
private val networkRepository: NetworkRepository,
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
private val radioPrefs: RadioPrefs,
private val interfaceFactory: Lazy<InterfaceFactory>,
private val analytics: PlatformAnalytics,
) : RadioInterfaceService {
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
override val supportedDeviceTypes: List<org.meshtastic.core.model.DeviceType> =
listOf(
org.meshtastic.core.model.DeviceType.BLE,
org.meshtastic.core.model.DeviceType.TCP,
org.meshtastic.core.model.DeviceType.USB,
)
private val _receivedData = MutableSharedFlow<ByteArray>(extraBufferCapacity = 64)
override val receivedData: SharedFlow<ByteArray> = _receivedData
private val _connectionError = MutableSharedFlow<String>(extraBufferCapacity = 64)
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value)
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
private val logSends = false
private val logReceives = false
private lateinit var sentPacketsLog: BinaryLogFile
private lateinit var receivedPacketsLog: BinaryLogFile
val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") }
override val serviceScope: CoroutineScope
get() = _serviceScope
/** We recreate this scope each time we stop an interface */
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
private var radioIf: RadioTransport = NopInterface("")
/**
* true if we have started our interface
*
* Note: an interface may be started without necessarily yet having a connection
*/
private var isStarted = false
@Volatile private var listenersInitialized = false
private fun initStateListeners() {
if (listenersInitialized) return
synchronized(this) {
if (listenersInitialized) return
listenersInitialized = true
radioPrefs.devAddr
.onEach { addr ->
if (_currentDeviceAddressFlow.value != addr) {
_currentDeviceAddressFlow.value = addr
startInterface()
}
}
.launchIn(processLifecycle.coroutineScope)
bluetoothRepository.state
.onEach { state ->
if (state.enabled) {
startInterface()
} else if (radioIf is BleRadioInterface) {
stopInterface()
}
}
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
networkRepository.networkAvailable
.onEach { state ->
if (state) {
startInterface()
} else if (radioIf is TCPInterface) {
stopInterface()
}
}
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
}
}
companion object {
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
}
private var lastHeartbeatMillis = 0L
fun keepAlive(now: Long = nowMillis) {
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
if (radioIf is SerialInterface) {
Logger.i { "Sending ToRadio heartbeat" }
val heartbeat = ToRadio(heartbeat = Heartbeat())
handleSendToRadio(heartbeat.encode())
} else {
// For BLE and TCP this will check if the connection is still alive
radioIf.keepAlive()
}
lastHeartbeatMillis = now
}
}
/** Constructs a full radio address for the specific interface type. */
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
override fun isMockInterface(): Boolean =
BuildConfig.DEBUG || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
override fun getDeviceAddress(): String? {
// If the user has unpaired our device, treat things as if we don't have one
return _currentDeviceAddressFlow.value
}
/**
* Like getDeviceAddress, but filtered to return only devices we are currently bonded with
*
* at
*
* where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
* path)
*/
fun getBondedDeviceAddress(): String? {
// If the user has unpaired our device, treat things as if we don't have one
val address = getDeviceAddress()
return if (interfaceFactory.value.addressValid(address)) {
address
} else {
null
}
}
private fun broadcastConnectionChanged(newState: ConnectionState) {
Logger.d { "Broadcasting connection state change to $newState" }
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionState.emit(newState) }
}
// Send a packet/command out the radio link, this routine can block if it needs to
private fun handleSendToRadio(p: ByteArray) {
radioIf.handleSendToRadio(p)
emitSendActivity()
}
// Handle an incoming packet from the radio, broadcasts it as an android intent
@Suppress("TooGenericExceptionCaught")
override fun handleFromRadio(bytes: ByteArray) {
if (logReceives) {
try {
receivedPacketsLog.write(bytes)
receivedPacketsLog.flush()
} catch (t: Throwable) {
Logger.w(t) { "Failed to write receive log in handleFromRadio" }
}
}
try {
processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
emitReceiveActivity()
} catch (t: Throwable) {
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
}
}
override fun onConnect() {
if (_connectionState.value != ConnectionState.Connected) {
broadcastConnectionChanged(ConnectionState.Connected)
}
}
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
if (errorMessage != null) {
processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) }
}
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
if (_connectionState.value != newTargetState) {
broadcastConnectionChanged(newTargetState)
}
}
/** Start our configured interface (if it isn't already running) */
private fun startInterface() {
if (radioIf !is NopInterface) {
// Already running
return
}
val isTestLab = Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
val address =
getBondedDeviceAddress()
?: if (isTestLab) {
mockInterfaceAddress
} else {
null
}
if (address == null) {
Logger.w { "No bonded mesh radio, can't start interface" }
} else {
Logger.i { "Starting radio ${address.anonymize}" }
isStarted = true
if (logSends) {
sentPacketsLog = BinaryLogFile(context, "sent_log.pb")
}
if (logReceives) {
receivedPacketsLog = BinaryLogFile(context, "receive_log.pb")
}
radioIf = interfaceFactory.value.createInterface(address, this)
startHeartbeat()
}
}
private var heartbeatJob: kotlinx.coroutines.Job? = null
private fun startHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob =
serviceScope.launch {
while (true) {
delay(HEARTBEAT_INTERVAL_MILLIS)
keepAlive()
}
}
}
private fun stopInterface() {
val r = radioIf
Logger.i { "stopping interface $r" }
isStarted = false
radioIf = interfaceFactory.value.nopInterface
r.close()
// cancel any old jobs and get ready for the new ones
_serviceScope.cancel("stopping interface")
_serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
if (logSends) {
sentPacketsLog.close()
}
if (logReceives) {
receivedPacketsLog.close()
}
// Don't broadcast disconnects if we were just using the nop device
if (r !is NopInterface) {
onDisconnect(isPermanent = true) // Tell any clients we are now offline
}
}
/**
* Change to a new device
*
* @return true if the device changed, false if no change
*/
private fun setBondedDeviceAddress(address: String?): Boolean =
if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.Connected) {
Logger.w { "Ignoring setBondedDevice ${address.anonymize}, because we are already using that device" }
false
} else {
// Record that this use has configured a new radio
analytics.track("mesh_bond")
// Ignore any errors that happen while closing old device
ignoreException { stopInterface() }
// The device address "n" can be used to mean none
Logger.d { "Setting bonded device to ${address.anonymize}" }
// Stores the address if non-null, otherwise removes the pref
radioPrefs.setDevAddr(address)
_currentDeviceAddressFlow.value = address
// Force the service to reconnect
startInterface()
true
}
override fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions {
setBondedDeviceAddress(deviceAddr)
}
/**
* If the service is not currently connected to the radio, try to connect now. At boot the radio interface service
* will not connect to a radio until this call is received.
*/
override fun connect() = toRemoteExceptions {
// We don't start actually talking to our device until MeshService binds to us - this prevents
// broadcasting connection events before MeshService is ready to receive them
startInterface()
initStateListeners()
}
override fun sendToRadio(bytes: ByteArray) {
// Do this in the IO thread because it might take a while (and we don't care about the result code)
_serviceScope.handledLaunch { handleSendToRadio(bytes) }
}
private val _meshActivity =
MutableSharedFlow<MeshActivity>(
extraBufferCapacity = 64,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
)
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity.asSharedFlow()
private fun emitSendActivity() {
_meshActivity.tryEmit(MeshActivity.Send)
}
private fun emitReceiveActivity() {
_meshActivity.tryEmit(MeshActivity.Receive)
}
}

View file

@ -1,380 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import android.annotation.SuppressLint
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.retryBleOperation
import org.meshtastic.core.ble.toMeshtasticRadioProfile
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 1000L
private const val CONNECTION_TIMEOUT_MS = 15_000L
private val SCAN_TIMEOUT = 5.seconds
/**
* A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
*
* This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
* - Bonding and discovery.
* - Automatic reconnection logic.
* - MTU and connection parameter monitoring.
* - Routing raw byte packets between the radio and [RadioInterfaceService].
*
* @param serviceScope The coroutine scope to use for launching coroutines.
* @param scanner The BLE scanner.
* @param bluetoothRepository The Bluetooth repository.
* @param connectionFactory The BLE connection factory.
* @param service The [RadioInterfaceService] to use for handling radio events.
* @param address The BLE address of the device to connect to.
*/
@SuppressLint("MissingPermission")
class BleRadioInterface(
private val serviceScope: CoroutineScope,
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
private val service: RadioInterfaceService,
val address: String,
) : RadioTransport {
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
serviceScope.launch {
try {
bleConnection.disconnect()
} catch (e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
}
}
val (isPermanent, msg) = throwable.toDisconnectReason()
service.onDisconnect(isPermanent, errorMessage = msg)
}
private val connectionScope: CoroutineScope =
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
private val writeMutex: Mutex = Mutex()
private var connectionStartTime: Long = 0
private var packetsReceived: Int = 0
private var packetsSent: Int = 0
private var bytesReceived: Long = 0
private var bytesSent: Long = 0
@Volatile private var isFullyConnected = false
init {
connect()
}
// --- Connection & Discovery Logic ---
/** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
private suspend fun findDevice(): BleDevice {
bluetoothRepository.state.value.bondedDevices
.firstOrNull { it.address == address }
?.let {
return it
}
Logger.i { "[$address] Device not found in bonded list, scanning..." }
repeat(SCAN_RETRY_COUNT) { attempt ->
try {
val d =
kotlinx.coroutines.withTimeoutOrNull(SCAN_TIMEOUT) {
scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first {
it.address == address
}
}
if (d != null) return d
} catch (e: Exception) {
Logger.v(e) { "Scan attempt failed or timed out" }
}
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY_MS)
}
}
throw RadioNotConnectedException("Device not found at address $address")
}
private fun connect() {
connectionScope.launch {
val device = findDevice()
bleConnection.connectionState
.onEach { state ->
if (state is BleConnectionState.Disconnected && isFullyConnected) {
isFullyConnected = false
onDisconnected(state)
}
}
.catch { e ->
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
handleFailure(e)
}
.launchIn(connectionScope)
while (isActive) {
try {
// Add a delay to allow any pending background disconnects (from a previous close() call)
// to complete and the Android BLE stack to settle before we attempt a new connection.
@Suppress("MagicNumber")
val connectDelayMs = 1000L
kotlinx.coroutines.delay(connectDelayMs)
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (state !is BleConnectionState.Connected) {
// Kable on Android occasionally fails the first connection attempt with NotConnectedException
// if the previous peripheral wasn't fully cleaned up by the OS. A quick retry resolves it.
Logger.w { "[$address] First connection attempt failed, retrying in 1.5s..." }
@Suppress("MagicNumber")
val retryDelayMs = 1500L
kotlinx.coroutines.delay(retryDelayMs)
state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
}
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
isFullyConnected = true
onConnected()
discoverServicesAndSetupCharacteristics()
// Suspend here until Kable drops the connection
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
Logger.i { "[$address] BLE connection dropped, preparing to reconnect..." }
} catch (e: kotlinx.coroutines.CancellationException) {
Logger.d { "[$address] BLE connection coroutine cancelled" }
throw e
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
handleFailure(e)
// Wait before retrying to prevent hot loops
@Suppress("MagicNumber")
kotlinx.coroutines.delay(5000L)
}
}
}
}
private suspend fun onConnected() {
try {
bleConnection.deviceFlow.first()?.let { device ->
val rssi = retryBleOperation(tag = address) { device.readRssi() }
Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
}
}
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
radioService = null
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.w {
"[$address] BLE disconnected, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
// Note: Disconnected state in commonMain doesn't currently carry a reason.
// We might want to add that later if needed.
service.onDisconnect(false, errorMessage = "Disconnected")
}
private suspend fun discoverServicesAndSetupCharacteristics() {
try {
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
val radioService = service.toMeshtasticRadioProfile()
// Wire up notifications
radioService.fromRadio
.onEach { packet ->
Logger.d { "[$address] Received packet fromRadio (${packet.size} bytes)" }
dispatchPacket(packet)
}
.catch { e ->
Logger.w(e) { "[$address] Error in fromRadio flow" }
handleFailure(e)
}
.launchIn(this)
radioService.logRadio
.onEach { packet ->
Logger.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
dispatchPacket(packet)
}
.catch { e ->
Logger.w(e) { "[$address] Error in logRadio flow" }
handleFailure(e)
}
.launchIn(this)
// Store reference for handleSendToRadio
this@BleRadioInterface.radioService = radioService
Logger.i { "[$address] Profile service active and characteristics subscribed" }
// Log negotiated MTU for diagnostics
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
this@BleRadioInterface.service.onConnect()
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
bleConnection.disconnect()
handleFailure(e)
}
}
private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
// --- RadioTransport Implementation ---
/**
* Sends a packet to the radio with retry support.
*
* @param p The packet to send.
*/
override fun handleSendToRadio(p: ByteArray) {
val currentService = radioService
if (currentService != null) {
connectionScope.launch {
writeMutex.withLock {
try {
retryBleOperation(tag = address) { currentService.sendToRadio(p) }
packetsSent++
bytesSent += p.size
Logger.d {
"[$address] Successfully wrote packet #$packetsSent " +
"to toRadioCharacteristic - " +
"${p.size} bytes (Total TX: $bytesSent bytes)"
}
} catch (e: Exception) {
Logger.w(e) {
"[$address] Failed to write packet to toRadioCharacteristic after " +
"$packetsSent successful writes"
}
handleFailure(e)
}
}
}
} else {
Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
}
}
override fun keepAlive() {
Logger.d { "[$address] BLE keepAlive" }
}
/** Closes the connection to the device. */
override fun close() {
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.i {
"[$address] Disconnecting. " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
connectionScope.launch {
bleConnection.disconnect()
service.onDisconnect(true)
connectionScope.cancel()
}
}
private fun dispatchPacket(packet: ByteArray) {
packetsReceived++
bytesReceived += packet.size
Logger.d {
"[$address] Dispatching packet to service.handleFromRadio() - " +
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
}
service.handleFromRadio(packet)
}
private fun handleFailure(throwable: Throwable) {
val (isPermanent, msg) = throwable.toDisconnectReason()
service.onDisconnect(isPermanent, errorMessage = msg)
}
private fun Throwable.toDisconnectReason(): Pair<Boolean, String> {
val isPermanent =
this::class.simpleName == "BluetoothUnavailableException" ||
this::class.simpleName == "ManagerClosedException"
val msg =
when {
this is RadioNotConnectedException -> this.message ?: "Device not found"
this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing"
this::class.simpleName == "GattException" -> "GATT Error: ${this.message}"
else -> this.message ?: this::class.simpleName ?: "Unknown"
}
return Pair(isPermanent, msg)
}
}

View file

@ -1,40 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.repository.RadioInterfaceService
/** Factory for creating `BleRadioInterface` instances. */
@Single
class BleRadioInterfaceFactory(
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
) {
fun create(rest: String, service: RadioInterfaceService): BleRadioInterface = BleRadioInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = rest,
)
}

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService
/** Bluetooth backend implementation. */
@Single
class BleRadioInterfaceSpec(private val factory: BleRadioInterfaceFactory) : InterfaceSpec<BleRadioInterface> {
override fun createInterface(rest: String, service: RadioInterfaceService): BleRadioInterface =
factory.create(rest, service)
/** Return true if this address is still acceptable. For Kable we don't strictly require prior bonding. */
override fun addressValid(rest: String): Boolean {
// We no longer strictly require the device to be in the bonded list before attempting connection,
// as Kable and Android will handle bonding seamlessly during connection/characteristic access if needed.
return rest.isNotBlank()
}
}

View file

@ -1,67 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
/**
* Entry point for create radio backend instances given a specific address.
*
* This class is responsible for building and dissecting radio addresses based upon their interface type and the "rest"
* of the address (which varies per implementation).
*/
@Single
class InterfaceFactory(
private val nopInterfaceFactory: NopInterfaceFactory,
private val bluetoothSpec: Lazy<BleRadioInterfaceSpec>,
private val mockSpec: Lazy<MockInterfaceSpec>,
private val serialSpec: Lazy<SerialInterfaceSpec>,
private val tcpSpec: Lazy<TCPInterfaceSpec>,
) {
internal val nopInterface by lazy { nopInterfaceFactory.create("") }
private val specMap: Map<InterfaceId, InterfaceSpec<*>>
get() =
mapOf(
InterfaceId.BLUETOOTH to bluetoothSpec.value,
InterfaceId.MOCK to mockSpec.value,
InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory),
InterfaceId.SERIAL to serialSpec.value,
InterfaceId.TCP to tcpSpec.value,
)
fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
fun createInterface(address: String, service: RadioInterfaceService): RadioTransport {
val (spec, rest) = splitAddress(address)
return spec?.createInterface(rest, service) ?: nopInterface
}
fun addressValid(address: String?): Boolean = address?.let {
val (spec, rest) = splitAddress(it)
spec?.addressValid(rest)
} ?: false
private fun splitAddress(address: String): Pair<InterfaceSpec<*>?, String> {
val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it] }
val rest = address.substring(1)
return Pair(c, rest)
}
}

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.meshtastic.core.repository.RadioTransport
/**
* Radio interface factory service provider interface. Each radio backend implementation needs to have a factory to
* create new instances. These instances are specific to a particular address. This interface defines a common API
* across all radio interfaces for obtaining implementation instances.
*
* This is primarily used in conjunction with Dagger assisted injection for each backend interface type.
*/
interface InterfaceFactorySpi<T : RadioTransport> {
fun create(rest: String): T
}

View file

@ -1,28 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
/** This interface defines the contract that all radio backend implementations must adhere to. */
interface InterfaceSpec<T : RadioTransport> {
fun createInterface(rest: String, service: RadioInterfaceService): T
/** Return true if this address is still acceptable. For BLE that means, still bonded */
fun addressValid(rest: String): Boolean = true
}

View file

@ -1,358 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import okio.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getInitials
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.Routing
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import org.meshtastic.proto.User
import kotlin.random.Random
import org.meshtastic.proto.Channel as ProtoChannel
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Config.LoRaConfig.RegionCode.TW)
private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY)
/** A simulated interface that is used for testing in the simulator */
@Suppress("detekt:TooManyFunctions", "detekt:MagicNumber")
class MockInterface(private val service: RadioInterfaceService, val address: String) : RadioTransport {
companion object {
private const val MY_NODE = 0x42424242
}
private var currentPacketId = 50
// an infinite sequence of ints
private val packetIdSequence = generateSequence { currentPacketId++ }.iterator()
init {
Logger.i { "Starting the mock interface" }
service.onConnect() // Tell clients they can use the API
}
override fun handleSendToRadio(p: ByteArray) {
val pr = ToRadio.ADAPTER.decode(p)
val packet = pr.packet
if (packet != null) {
sendQueueStatus(packet.id)
}
val data = packet?.decoded
when {
(pr.want_config_id ?: 0) != 0 -> sendConfigResponse(pr.want_config_id ?: 0)
data != null && data.portnum == PortNum.ADMIN_APP ->
handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload))
packet != null && packet.want_ack == true -> sendFakeAck(pr)
else -> Logger.i { "Ignoring data sent to mock interface $pr" }
}
}
private fun handleAdminPacket(pr: ToRadio, d: AdminMessage) {
val packet = pr.packet ?: return
when {
d.get_config_request == AdminMessage.ConfigType.LORA_CONFIG ->
sendAdmin(packet.to, packet.from, packet.id) {
copy(get_config_response = Config(lora = defaultLoRaConfig))
}
(d.get_channel_request ?: 0) != 0 ->
sendAdmin(packet.to, packet.from, packet.id) {
copy(
get_channel_response =
ProtoChannel(
index = (d.get_channel_request ?: 0) - 1, // 0 based on the response
settings = if (d.get_channel_request == 1) Channel.default.settings else null,
role =
if (d.get_channel_request == 1) {
ProtoChannel.Role.PRIMARY
} else {
ProtoChannel.Role.DISABLED
},
),
)
}
d.get_module_config_request == AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG ->
sendAdmin(packet.to, packet.from, packet.id) {
copy(
get_module_config_response =
ModuleConfig(
statusmessage =
ModuleConfig.StatusMessageConfig(node_status = "Going to the farm.. to grow wheat."),
),
)
}
else -> Logger.i { "Ignoring admin sent to mock interface $d" }
}
}
override fun close() {
Logger.i { "Closing the mock interface" }
}
// / Generate a fake text message from a node
private fun makeTextMessage(numIn: Int) = FromRadio(
packet =
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "This simulated node sends Hi!".encodeUtf8(),
),
),
)
private fun makeNeighborInfo(numIn: Int) = FromRadio(
packet =
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.NEIGHBORINFO_APP,
payload =
NeighborInfo(
node_id = numIn,
last_sent_by_id = numIn,
node_broadcast_interval_secs = 60,
neighbors =
listOf(
Neighbor(
node_id = numIn + 1,
snr = 10.0f,
last_rx_time = nowSeconds.toInt(),
node_broadcast_interval_secs = 60,
),
Neighbor(
node_id = numIn + 2,
snr = 12.0f,
last_rx_time = nowSeconds.toInt(),
node_broadcast_interval_secs = 60,
),
),
)
.encode()
.toByteString(),
),
),
)
private fun makePosition(numIn: Int) = FromRadio(
packet =
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.POSITION_APP,
payload =
ProtoPosition(
latitude_i = org.meshtastic.core.model.Position.degI(32.776665),
longitude_i = org.meshtastic.core.model.Position.degI(-96.796989),
altitude = 150,
time = nowSeconds.toInt(),
precision_bits = 15,
)
.encode()
.toByteString(),
),
),
)
private fun makeTelemetry(numIn: Int) = FromRadio(
packet =
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.TELEMETRY_APP,
payload =
Telemetry(
time = nowSeconds.toInt(),
device_metrics =
DeviceMetrics(
battery_level = 85,
voltage = 4.1f,
channel_utilization = 0.12f,
air_util_tx = 0.05f,
uptime_seconds = 123456,
),
)
.encode()
.toByteString(),
),
),
)
private fun makeNodeStatus(numIn: Int) = FromRadio(
packet =
MeshPacket(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.NODE_STATUS_APP,
payload =
StatusMessage(status = "Going to the farm.. to grow wheat.").encode().toByteString(),
),
),
)
private fun makeDataPacket(fromIn: Int, toIn: Int, data: Data) = FromRadio(
packet =
MeshPacket(
id = packetIdSequence.next(),
from = fromIn,
to = toIn,
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded = data,
),
)
private fun makeAck(fromIn: Int, toIn: Int, msgId: Int) = makeDataPacket(
fromIn,
toIn,
Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId),
)
private fun sendQueueStatus(msgId: Int) = service.handleFromRadio(
FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(),
)
private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminMessage.() -> AdminMessage) {
val adminMsg = AdminMessage().initFn()
val p =
makeDataPacket(
fromIn,
toIn,
Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId),
)
service.handleFromRadio(p.encode())
}
// / Send a fake ack packet back if the sender asked for want_ack
private fun sendFakeAck(pr: ToRadio) = service.serviceScope.handledLaunch {
val packet = pr.packet ?: return@handledLaunch
delay(2000)
service.handleFromRadio(makeAck(MY_NODE + 1, packet.from ?: 0, packet.id).encode())
}
private fun sendConfigResponse(configId: Int) {
Logger.d { "Sending mock config response" }
// / Generate a fake node info entry
@Suppress("MagicNumber")
fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = FromRadio(
node_info =
NodeInfo(
num = numIn,
user =
User(
id = DataPacket.nodeNumToDefaultId(numIn),
long_name = "Sim " + Integer.toHexString(numIn),
short_name = getInitials("Sim " + Integer.toHexString(numIn)),
hw_model = HardwareModel.ANDROID_SIM,
),
position =
ProtoPosition(
latitude_i = org.meshtastic.core.model.Position.degI(lat),
longitude_i = org.meshtastic.core.model.Position.degI(lon),
altitude = 35,
time = nowSeconds.toInt(),
precision_bits = Random.nextInt(10, 19),
),
),
)
// Simulated network data to feed to our app
val packets =
arrayOf(
// MyNodeInfo
FromRadio(my_info = ProtoMyNodeInfo(my_node_num = MY_NODE)),
FromRadio(
metadata = DeviceMetadata(firmware_version = "9.9.9.abcdefg", hw_model = HardwareModel.ANDROID_SIM),
),
// Fake NodeDB
makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas
makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson
FromRadio(config = Config(lora = defaultLoRaConfig)),
FromRadio(config = Config(lora = defaultLoRaConfig)),
FromRadio(channel = defaultChannel),
FromRadio(config_complete_id = configId),
// Done with config response, now pretend to receive some text messages
makeTextMessage(MY_NODE + 1),
makeNeighborInfo(MY_NODE + 1),
makePosition(MY_NODE + 1),
makeTelemetry(MY_NODE + 1),
makeNodeStatus(MY_NODE + 1),
)
packets.forEach { p -> service.handleFromRadio(p.encode()) }
}
}

View file

@ -1,26 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService
/** Factory for creating `MockInterface` instances. */
@Single
class MockInterfaceFactory {
fun create(rest: String, service: RadioInterfaceService): MockInterface = MockInterface(service, rest)
}

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService
/** Mock interface backend implementation. */
@Single
class MockInterfaceSpec(private val factory: MockInterfaceFactory) : InterfaceSpec<MockInterface> {
override fun createInterface(rest: String, service: RadioInterfaceService): MockInterface =
factory.create(rest, service)
/** Return true if this address is still acceptable. For BLE that means, still bonded */
override fun addressValid(rest: String): Boolean = true
}

View file

@ -1,29 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.meshtastic.core.repository.RadioTransport
class NopInterface(val address: String) : RadioTransport {
override fun handleSendToRadio(p: ByteArray) {
// No-op
}
override fun close() {
// No-op
}
}

View file

@ -1,25 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
/** Factory for creating `NopInterface` instances. */
@Single
class NopInterfaceFactory {
fun create(rest: String): NopInterface = NopInterface(rest)
}

View file

@ -1,26 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService
/** No-op interface backend implementation. */
@Single
class NopInterfaceSpec(private val factory: NopInterfaceFactory) : InterfaceSpec<NopInterface> {
override fun createInterface(rest: String, service: RadioInterfaceService): NopInterface = factory.create(rest)
}

View file

@ -1,134 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.feature.connections.repository.SerialConnection
import org.meshtastic.feature.connections.repository.SerialConnectionListener
import org.meshtastic.feature.connections.repository.UsbRepository
import java.util.concurrent.atomic.AtomicReference
/** An interface that assumes we are talking to a meshtastic device via USB serial */
class SerialInterface(
service: RadioInterfaceService,
private val usbRepository: UsbRepository,
private val address: String,
) : StreamInterface(service) {
private var connRef = AtomicReference<SerialConnection?>()
init {
connect()
}
override fun onDeviceDisconnect(waitForStopped: Boolean) {
connRef.get()?.close(waitForStopped)
super.onDeviceDisconnect(waitForStopped)
}
override fun connect() {
val deviceMap = usbRepository.serialDevices.value
val device =
if (deviceMap.containsKey(address)) {
deviceMap[address]!!
} else {
deviceMap.map { (_, driver) -> driver }.firstOrNull()
}
if (device == null) {
Logger.e { "[$address] Serial device not found at address" }
} else {
val connectStart = nowMillis
Logger.i { "[$address] Opening serial device: $device" }
var packetsReceived = 0
var bytesReceived = 0L
var connectionStartTime = 0L
val onConnect: () -> Unit = {
connectionStartTime = nowMillis
val connectionTime = connectionStartTime - connectStart
Logger.i { "[$address] Serial device connected in ${connectionTime}ms" }
super.connect()
}
usbRepository
.createSerialConnection(
device,
object : SerialConnectionListener {
override fun onMissingPermission() {
Logger.e {
"[$address] Serial connection failed - missing USB permissions for device: $device"
}
}
override fun onConnected() {
onConnect.invoke()
}
override fun onDataReceived(bytes: ByteArray) {
packetsReceived++
bytesReceived += bytes.size
Logger.d {
"[$address] Serial received packet #$packetsReceived - " +
"${bytes.size} byte(s) (Total RX: $bytesReceived bytes)"
}
bytes.forEach(::readChar)
}
override fun onDisconnected(thrown: Exception?) {
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
thrown?.let { e ->
// USB errors are common when unplugging; log as warning to avoid Crashlytics noise
Logger.w(e) { "[$address] Serial error after ${uptime}ms: ${e.message}" }
}
Logger.w {
"[$address] Serial device disconnected - " +
"Device: $device, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes)"
}
onDeviceDisconnect(false)
}
},
)
.also { conn ->
connRef.set(conn)
conn.connect()
}
}
}
override fun keepAlive() {
Logger.d { "[$address] Serial keepAlive" }
}
override fun sendBytes(p: ByteArray) {
val conn = connRef.get()
if (conn != null) {
Logger.d { "[$address] Serial sending ${p.size} bytes" }
conn.sendBytes(p)
} else {
Logger.w { "[$address] Serial connection not available, cannot send ${p.size} bytes" }
}
}
}

View file

@ -1,28 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.feature.connections.repository.UsbRepository
/** Factory for creating `SerialInterface` instances. */
@Single
class SerialInterfaceFactory(private val usbRepository: UsbRepository) {
fun create(rest: String, service: RadioInterfaceService): SerialInterface =
SerialInterface(service, usbRepository, rest)
}

View file

@ -1,51 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import android.hardware.usb.UsbManager
import com.hoho.android.usbserial.driver.UsbSerialDriver
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.feature.connections.repository.UsbRepository
/** Serial/USB interface backend implementation. */
@Single
class SerialInterfaceSpec(
private val factory: SerialInterfaceFactory,
private val usbManager: UsbManager,
private val usbRepository: UsbRepository,
) : InterfaceSpec<SerialInterface> {
override fun createInterface(rest: String, service: RadioInterfaceService): SerialInterface =
factory.create(rest, service)
override fun addressValid(rest: String): Boolean {
usbRepository.serialDevices.value.filterValues { usbManager.hasPermission(it.device) }
findSerial(rest)?.let { d ->
return usbManager.hasPermission(d.device)
}
return false
}
internal fun findSerial(rest: String): UsbSerialDriver? {
val deviceMap = usbRepository.serialDevices.value
return if (deviceMap.containsKey(rest)) {
deviceMap[rest]!!
} else {
deviceMap.map { (_, driver) -> driver }.firstOrNull()
}
}
}

View file

@ -1,74 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
/**
* An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP
* probably).
*
* Delegates framing logic to [StreamFrameCodec] from `core:network`.
*/
abstract class StreamInterface(protected val service: RadioInterfaceService) : RadioTransport {
private val codec = StreamFrameCodec(onPacketReceived = { service.handleFromRadio(it) }, logTag = "StreamInterface")
override fun close() {
Logger.d { "Closing stream for good" }
onDeviceDisconnect(true)
}
/**
* Tell MeshService our device has gone away, but wait for it to come back
*
* @param waitForStopped if true we should wait for the manager to finish - must be false if called from inside the
* manager callbacks
*/
protected open fun onDeviceDisconnect(waitForStopped: Boolean) {
service.onDisconnect(
isPermanent = true,
) // if USB device disconnects it is definitely permanently gone, not sleeping)
}
protected open fun connect() {
// Before telling mesh service, send a few START1s to wake a sleeping device
sendBytes(StreamFrameCodec.WAKE_BYTES)
// Now tell clients they can (finally use the api)
service.onConnect()
}
abstract fun sendBytes(p: ByteArray)
// If subclasses need to flush at the end of a packet they can implement
open fun flushBytes() {}
override fun handleSendToRadio(p: ByteArray) {
// This method is called from a continuation and it might show up late, so check for uart being null
service.serviceScope.launch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) }
}
/** Process a single incoming byte through the stream framing state machine. */
protected fun readChar(c: Byte) {
codec.processInputByte(c)
}
}

View file

@ -1,91 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.network.transport.TcpTransport
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
/**
* Android TCP radio interface thin adapter over the shared [TcpTransport] from `core:network`.
*
* Manages the mapping between the Android-specific [StreamInterface]/[RadioTransport] contract and the shared transport
* layer.
*/
open class TCPInterface(
service: RadioInterfaceService,
private val dispatchers: CoroutineDispatchers,
private val address: String,
) : StreamInterface(service) {
companion object {
const val SERVICE_PORT = StreamFrameCodec.DEFAULT_TCP_PORT
}
private val transport =
TcpTransport(
dispatchers = dispatchers,
scope = service.serviceScope,
listener =
object : TcpTransport.Listener {
override fun onConnected() {
super@TCPInterface.connect()
}
override fun onDisconnected() {
// Transport already performed teardown; only propagate lifecycle to StreamInterface.
super@TCPInterface.onDeviceDisconnect(false)
}
override fun onPacketReceived(bytes: ByteArray) {
service.handleFromRadio(bytes)
}
},
logTag = "TCPInterface[$address]",
)
init {
connect()
}
override fun sendBytes(p: ByteArray) {
// Direct byte sending is handled by the transport; this is used by StreamInterface for serial compat
Logger.d { "[$address] TCPInterface.sendBytes delegated to transport" }
}
override fun onDeviceDisconnect(waitForStopped: Boolean) {
transport.stop()
super.onDeviceDisconnect(waitForStopped)
}
override fun connect() {
transport.start(address)
}
override fun keepAlive() {
Logger.d { "[$address] TCP keepAlive" }
service.serviceScope.handledLaunch { transport.sendHeartbeat() }
}
override fun handleSendToRadio(p: ByteArray) {
service.serviceScope.handledLaunch { transport.sendPacket(p) }
}
}

View file

@ -1,27 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.RadioInterfaceService
/** Factory for creating `TCPInterface` instances. */
@Single
class TCPInterfaceFactory(private val dispatchers: CoroutineDispatchers) {
fun create(rest: String, service: RadioInterfaceService): TCPInterface = TCPInterface(service, dispatchers, rest)
}

View file

@ -1,27 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.repository.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService
/** TCP interface backend implementation. */
@Single
class TCPInterfaceSpec(private val factory: TCPInterfaceFactory) : InterfaceSpec<TCPInterface> {
override fun createInterface(rest: String, service: RadioInterfaceService): TCPInterface =
factory.create(rest, service)
}

View file

@ -1,41 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.koin.core.annotation.Single
import org.meshtastic.app.messaging.domain.worker.SendMessageWorker
import org.meshtastic.core.repository.MeshWorkerManager
@Single
class AndroidMeshWorkerManager(private val workManager: WorkManager) : MeshWorkerManager {
override fun enqueueSendMessage(packetId: Int) {
val workRequest =
OneTimeWorkRequestBuilder<SendMessageWorker>()
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
.build()
workManager.enqueueUniqueWork(
"${SendMessageWorker.WORK_NAME_PREFIX}$packetId",
ExistingWorkPolicy.REPLACE,
workRequest,
)
}
}

View file

@ -1,37 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** This receiver starts the MeshService on boot if a device was previously connected. */
class BootCompleteReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED != intent.action) {
return
}
val prefs = context.getSharedPreferences("mesh-prefs", Context.MODE_PRIVATE)
if (!prefs.contains("device_address")) {
return
}
MeshService.startService(context)
}
}

View file

@ -1,51 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import org.meshtastic.core.api.MeshtasticIntent
const val PREFIX = "com.geeksville.mesh"
const val ACTION_NODE_CHANGE = MeshtasticIntent.ACTION_NODE_CHANGE
const val ACTION_MESH_CONNECTED = MeshtasticIntent.ACTION_MESH_CONNECTED
const val ACTION_MESH_DISCONNECTED = MeshtasticIntent.ACTION_MESH_DISCONNECTED
const val ACTION_CONNECTION_CHANGED = MeshtasticIntent.ACTION_CONNECTION_CHANGED
const val ACTION_MESSAGE_STATUS = MeshtasticIntent.ACTION_MESSAGE_STATUS
const val ACTION_RECEIVED_TEXT_MESSAGE_APP = MeshtasticIntent.ACTION_RECEIVED_TEXT_MESSAGE_APP
const val ACTION_RECEIVED_POSITION_APP = MeshtasticIntent.ACTION_RECEIVED_POSITION_APP
const val ACTION_RECEIVED_NODEINFO_APP = MeshtasticIntent.ACTION_RECEIVED_NODEINFO_APP
const val ACTION_RECEIVED_TELEMETRY_APP = MeshtasticIntent.ACTION_RECEIVED_TELEMETRY_APP
const val ACTION_RECEIVED_ATAK_PLUGIN = MeshtasticIntent.ACTION_RECEIVED_ATAK_PLUGIN
const val ACTION_RECEIVED_ATAK_FORWARDER = MeshtasticIntent.ACTION_RECEIVED_ATAK_FORWARDER
const val ACTION_RECEIVED_DETECTION_SENSOR_APP = MeshtasticIntent.ACTION_RECEIVED_DETECTION_SENSOR_APP
const val ACTION_RECEIVED_PRIVATE_APP = MeshtasticIntent.ACTION_RECEIVED_PRIVATE_APP
fun actionReceived(portNum: String) = "$PREFIX.RECEIVED.$portNum"
//
// standard EXTRA bundle definitions
//
const val EXTRA_CONNECTED = MeshtasticIntent.EXTRA_CONNECTED
const val EXTRA_PROGRESS = "$PREFIX.Progress"
const val EXTRA_PERMANENT = "$PREFIX.Permanent"
const val EXTRA_PAYLOAD = MeshtasticIntent.EXTRA_PAYLOAD
const val EXTRA_NODEINFO = MeshtasticIntent.EXTRA_NODEINFO
const val EXTRA_PACKET_ID = MeshtasticIntent.EXTRA_PACKET_ID
const val EXTRA_STATUS = MeshtasticIntent.EXTRA_STATUS

View file

@ -1,63 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.PacketRepository
/** A [BroadcastReceiver] that handles "Mark as read" actions from notifications. */
class MarkAsReadReceiver :
BroadcastReceiver(),
KoinComponent {
private val packetRepository: PacketRepository by inject()
private val serviceNotifications: MeshServiceNotifications by inject()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ"
const val CONTACT_KEY = "contact_key"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == MARK_AS_READ_ACTION) {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: return
val pendingResult = goAsync()
scope.launch {
try {
packetRepository.clearUnreadCount(contactKey, nowMillis)
serviceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()
}
}
}
}
}

View file

@ -1,391 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.android.ext.android.inject
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.service.IMeshService
import org.meshtastic.feature.connections.NO_DEVICE_SELECTED
import org.meshtastic.proto.PortNum
@Suppress("TooManyFunctions", "LargeClass")
class MeshService : Service() {
private val radioInterfaceService: RadioInterfaceService by inject()
private val serviceRepository: ServiceRepository by inject()
private val packetHandler: PacketHandler by inject()
private val serviceBroadcasts: ServiceBroadcasts by inject()
private val nodeManager: NodeManager by inject()
private val messageProcessor: MeshMessageProcessor by inject()
private val commandSender: CommandSender by inject()
private val locationManager: MeshLocationManager by inject()
private val connectionManager: MeshConnectionManager by inject()
private val serviceNotifications: MeshServiceNotifications by inject()
private val router: MeshRouter by inject()
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private val myNodeNum: Int
get() = nodeManager.myNodeNum ?: throw RadioNotConnectedException()
companion object {
fun actionReceived(portNum: Int): String {
val portType = PortNum.fromValue(portNum)
val portStr = portType?.toString() ?: portNum.toString()
return actionReceived(portStr)
}
fun createIntent(context: Context) = Intent(context, MeshService::class.java)
fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) {
service.setDeviceAddress(address)
startService(context)
}
val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION)
val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION)
}
override fun onCreate() {
try {
super.onCreate()
} catch (e: IllegalStateException) {
// Hilt can throw IllegalStateException in tests if the component is not created.
// This can happen if the service is started by the system (e.g. after a crash or on boot)
// before the test rule has a chance to create the component.
if (e.message?.contains("HiltAndroidRule") == true) {
Logger.w(e) { "MeshService created before Hilt component was ready in test. Stopping service." }
stopSelf()
return
}
throw e
}
Logger.i { "Creating mesh service" }
serviceNotifications.initChannels()
packetHandler.start(serviceScope)
router.start(serviceScope)
nodeManager.start(serviceScope)
connectionManager.start(serviceScope)
messageProcessor.start(serviceScope)
commandSender.start(serviceScope)
serviceScope.handledLaunch { radioInterfaceService.connect() }
radioInterfaceService.receivedData
.onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum) }
.launchIn(serviceScope)
serviceRepository.serviceAction.onEach(router.actionHandler::onServiceAction).launchIn(serviceScope)
nodeManager.loadCachedNodeDB()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val a = radioInterfaceService.getDeviceAddress()
val wantForeground = a != null && a != NO_DEVICE_SELECTED
val notification = connectionManager.updateStatusNotification() as android.app.Notification
val foregroundServiceType =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
if (hasLocationPermission()) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
}
types
} else {
0
}
@Suppress("TooGenericExceptionCaught")
try {
ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, foregroundServiceType)
} catch (ex: SecurityException) {
// On Android 14+ starting a location FGS from the background can fail with SecurityException
// if the app is not in an allowed state. Retry without the location type if that was requested.
val connectedDeviceOnly =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
} else {
0
}
if (foregroundServiceType != connectedDeviceOnly) {
Logger.w(ex) {
"Failed to start foreground service with location type, retrying with connectedDevice only"
}
try {
ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, connectedDeviceOnly)
} catch (retryEx: Exception) {
Logger.e(retryEx) { "Failed to start foreground service even after retry" }
}
} else {
Logger.e(ex) { "SecurityException starting foreground service" }
}
} catch (ex: Exception) {
Logger.e(ex) { "Error starting foreground service" }
return START_NOT_STICKY
}
return if (!wantForeground) {
Logger.i { "Stopping mesh service because no device is selected" }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
START_NOT_STICKY
} else {
START_STICKY
}
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Logger.i { "Mesh service: onTaskRemoved" }
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onDestroy() {
Logger.i { "Destroying mesh service" }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
serviceJob.cancel()
super.onDestroy()
}
private val binder =
object : IMeshService.Stub() {
override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions {
Logger.d { "Passing through device change to radio service: ${deviceAddr?.take(8)}..." }
router.actionHandler.handleUpdateLastAddress(deviceAddr)
radioInterfaceService.setDeviceAddress(deviceAddr)
}
override fun subscribeReceiver(packageName: String, receiverName: String) {
serviceBroadcasts.subscribeReceiver(receiverName, packageName)
}
override fun getUpdateStatus(): Int = -4
override fun startFirmwareUpdate() {
// Not implemented yet
}
override fun getMyNodeInfo(): MyNodeInfo? = nodeManager.getMyNodeInfo()
override fun getMyId(): String = nodeManager.getMyId()
override fun getPacketId(): Int = commandSender.generatePacketId()
override fun setOwner(u: MeshUser) = toRemoteExceptions {
router.actionHandler.handleSetOwner(u, myNodeNum)
}
override fun setRemoteOwner(id: Int, destNum: Int, payload: ByteArray) = toRemoteExceptions {
router.actionHandler.handleSetRemoteOwner(id, destNum, payload)
}
override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions {
router.actionHandler.handleGetRemoteOwner(id, destNum)
}
override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) }
override fun getConfig(): ByteArray = toRemoteExceptions { commandSender.getCachedLocalConfig().encode() }
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
router.actionHandler.handleSetConfig(payload, myNodeNum)
}
override fun setRemoteConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions {
router.actionHandler.handleSetRemoteConfig(id, num, payload)
}
override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
router.actionHandler.handleGetRemoteConfig(id, destNum, config)
}
override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions {
router.actionHandler.handleSetModuleConfig(id, num, payload)
}
override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions {
router.actionHandler.handleGetModuleConfig(id, destNum, config)
}
override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions {
router.actionHandler.handleSetRingtone(destNum, ringtone)
}
override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions {
router.actionHandler.handleGetRingtone(id, destNum)
}
override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions {
router.actionHandler.handleSetCannedMessages(destNum, messages)
}
override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions {
router.actionHandler.handleGetCannedMessages(id, destNum)
}
override fun setChannel(payload: ByteArray?) = toRemoteExceptions {
router.actionHandler.handleSetChannel(payload, myNodeNum)
}
override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions {
router.actionHandler.handleSetRemoteChannel(id, num, payload)
}
override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions {
router.actionHandler.handleGetRemoteChannel(id, destNum, index)
}
override fun beginEditSettings(destNum: Int) = toRemoteExceptions {
router.actionHandler.handleBeginEditSettings(destNum)
}
override fun commitEditSettings(destNum: Int) = toRemoteExceptions {
router.actionHandler.handleCommitEditSettings(destNum)
}
override fun getChannelSet(): ByteArray = toRemoteExceptions {
commandSender.getCachedChannelSet().encode()
}
override fun getNodes(): List<NodeInfo> = nodeManager.getNodes()
override fun connectionState(): String = serviceRepository.connectionState.value.toString()
override fun startProvideLocation() {
locationManager.start(serviceScope) { commandSender.sendPosition(it) }
}
override fun stopProvideLocation() {
locationManager.stop()
}
override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions {
val myNodeNum = nodeManager.myNodeNum
if (myNodeNum != null) {
router.actionHandler.handleRemoveByNodenum(nodeNum, requestId, myNodeNum)
} else {
nodeManager.removeByNodenum(nodeNum)
}
}
override fun requestUserInfo(destNum: Int) = toRemoteExceptions {
if (destNum != myNodeNum) {
commandSender.requestUserInfo(destNum)
}
}
override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions {
router.actionHandler.handleRequestPosition(destNum, position, myNodeNum)
}
override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions {
commandSender.setFixedPosition(destNum, position)
}
override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions {
commandSender.requestTraceroute(requestId, destNum)
}
override fun requestNeighborInfo(requestId: Int, destNum: Int) = toRemoteExceptions {
router.actionHandler.handleRequestNeighborInfo(requestId, destNum)
}
override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions {
router.actionHandler.handleRequestShutdown(requestId, destNum)
}
override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions {
router.actionHandler.handleRequestReboot(requestId, destNum)
}
override fun rebootToDfu(destNum: Int) = toRemoteExceptions {
router.actionHandler.handleRebootToDfu(destNum)
}
override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions {
router.actionHandler.handleRequestFactoryReset(requestId, destNum)
}
override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) =
toRemoteExceptions {
router.actionHandler.handleRequestNodedbReset(requestId, destNum, preserveFavorites)
}
override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) = toRemoteExceptions {
router.actionHandler.handleGetDeviceConnectionStatus(requestId, destNum)
}
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) = toRemoteExceptions {
router.actionHandler.handleRequestTelemetry(requestId, destNum, type)
}
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) =
toRemoteExceptions {
router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash)
}
}
}

View file

@ -1,912 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.media.AudioAttributes
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.getSystemService
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.Single
import org.meshtastic.app.MainActivity
import org.meshtastic.app.R.raw
import org.meshtastic.app.service.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
import org.meshtastic.app.service.ReactionReceiver.Companion.REACT_ACTION
import org.meshtastic.app.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.local_stats_bad
import org.meshtastic.core.resources.local_stats_battery
import org.meshtastic.core.resources.local_stats_diagnostics_prefix
import org.meshtastic.core.resources.local_stats_dropped
import org.meshtastic.core.resources.local_stats_heap
import org.meshtastic.core.resources.local_stats_heap_value
import org.meshtastic.core.resources.local_stats_nodes
import org.meshtastic.core.resources.local_stats_noise
import org.meshtastic.core.resources.local_stats_relays
import org.meshtastic.core.resources.local_stats_traffic
import org.meshtastic.core.resources.local_stats_uptime
import org.meshtastic.core.resources.local_stats_utilization
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.mark_as_read
import org.meshtastic.core.resources.meshtastic_alerts_notifications
import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.core.resources.meshtastic_broadcast_notifications
import org.meshtastic.core.resources.meshtastic_low_battery_notifications
import org.meshtastic.core.resources.meshtastic_low_battery_temporary_remote_notifications
import org.meshtastic.core.resources.meshtastic_messages_notifications
import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
import org.meshtastic.core.resources.meshtastic_service_notifications
import org.meshtastic.core.resources.meshtastic_waypoints_notifications
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.core.resources.no_local_stats
import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.you
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Telemetry
import kotlin.time.Duration.Companion.minutes
/**
* Manages the creation and display of all app notifications.
*
* This class centralizes notification logic, including channel creation, builder configuration, and displaying
* notifications for various events like new messages, alerts, and service status changes.
*/
@Suppress("TooManyFunctions", "LongParameterList")
@Single
class MeshServiceNotificationsImpl(
private val context: Context,
private val packetRepository: Lazy<PacketRepository>,
private val nodeRepository: Lazy<NodeRepository>,
) : MeshServiceNotifications {
private val notificationManager = context.getSystemService<NotificationManager>()!!
companion object {
const val MAX_BATTERY_LEVEL = 100
private val NOTIFICATION_LIGHT_COLOR = Color.BLUE
private const val MAX_HISTORY_MESSAGES = 10
private const val MIN_CONTEXT_MESSAGES = 3
private const val SNIPPET_LENGTH = 30
private const val GROUP_KEY_MESSAGES = "com.geeksville.mesh.GROUP_MESSAGES"
private const val SUMMARY_ID = 1
private const val PERSON_ICON_SIZE = 128
private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
private const val STATS_UPDATE_MINUTES = 15
private val STATS_UPDATE_INTERVAL = STATS_UPDATE_MINUTES.minutes
private const val BULLET = ""
}
/**
* Sealed class to define the properties of each notification channel. This centralizes channel configuration and
* makes it type-safe.
*/
private sealed class NotificationType(
val channelId: String,
val channelNameRes: StringResource,
val importance: Int,
) {
object ServiceState :
NotificationType(
"my_service",
Res.string.meshtastic_service_notifications,
NotificationManager.IMPORTANCE_MIN,
)
object DirectMessage :
NotificationType(
"my_messages",
Res.string.meshtastic_messages_notifications,
NotificationManager.IMPORTANCE_HIGH,
)
object BroadcastMessage :
NotificationType(
"my_broadcasts",
Res.string.meshtastic_broadcast_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object Waypoint :
NotificationType(
"my_waypoints",
Res.string.meshtastic_waypoints_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object Alert :
NotificationType(
"my_alerts",
Res.string.meshtastic_alerts_notifications,
NotificationManager.IMPORTANCE_HIGH,
)
object NewNode :
NotificationType(
"new_nodes",
Res.string.meshtastic_new_nodes_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object LowBatteryLocal :
NotificationType(
"low_battery",
Res.string.meshtastic_low_battery_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object LowBatteryRemote :
NotificationType(
"low_battery_remote",
Res.string.meshtastic_low_battery_temporary_remote_notifications,
NotificationManager.IMPORTANCE_DEFAULT,
)
object Client :
NotificationType(
"client_notifications",
Res.string.client_notification,
NotificationManager.IMPORTANCE_HIGH,
)
companion object {
// A list of all types for easy initialization.
fun allTypes() = listOf(
ServiceState,
DirectMessage,
BroadcastMessage,
Waypoint,
Alert,
NewNode,
LowBatteryLocal,
LowBatteryRemote,
Client,
)
}
}
override fun clearNotifications() {
notificationManager.cancelAll()
}
/**
* Creates all necessary notification channels on devices running Android O or newer. This should be called once
* when the service is created.
*/
override fun initChannels() {
NotificationType.allTypes().forEach { type -> createNotificationChannel(type) }
}
private fun createNotificationChannel(type: NotificationType) {
if (notificationManager.getNotificationChannel(type.channelId) != null) return
val channelName = getString(type.channelNameRes)
val channel =
NotificationChannel(type.channelId, channelName, type.importance).apply {
lightColor = NOTIFICATION_LIGHT_COLOR
lockscreenVisibility = Notification.VISIBILITY_PUBLIC // Default, can be overridden
// Type-specific configurations
when (type) {
NotificationType.ServiceState -> {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
NotificationType.DirectMessage,
NotificationType.BroadcastMessage,
NotificationType.Waypoint,
NotificationType.NewNode,
NotificationType.LowBatteryLocal,
NotificationType.LowBatteryRemote,
-> {
setShowBadge(true)
setSound(
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
if (type == NotificationType.LowBatteryRemote) enableVibration(true)
}
NotificationType.Alert -> {
setShowBadge(true)
enableLights(true)
enableVibration(true)
setBypassDnd(true)
val alertSoundUri = "${SCHEME_ANDROID_RESOURCE}://${context.packageName}/${raw.alert}".toUri()
setSound(
alertSoundUri,
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM) // More appropriate for an alert
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
}
NotificationType.Client -> {
setShowBadge(true)
}
}
}
notificationManager.createNotificationChannel(channel)
}
private var cachedDeviceMetrics: DeviceMetrics? = null
private var cachedLocalStats: LocalStats? = null
private var nextStatsUpdateMillis: Long = 0
private var cachedMessage: String? = null
// region Public Notification Methods
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification {
// Update caches if telemetry is provided
telemetry?.let { t ->
t.local_stats?.let { stats ->
cachedLocalStats = stats
nextStatsUpdateMillis = nowMillis + STATS_UPDATE_INTERVAL.inWholeMilliseconds
}
t.device_metrics?.let { metrics -> cachedDeviceMetrics = metrics }
}
// Seeding from database if caches are still null (e.g. on restart or reconnection)
if (cachedLocalStats == null || cachedDeviceMetrics == null) {
val repo = nodeRepository.value
val myNodeNum = repo.myNodeInfo.value?.myNodeNum
if (myNodeNum != null) {
// We use runBlocking here because this is called from MeshConnectionManager's synchronous methods,
// and we only do this once if the cache is empty.
val nodes = runBlocking { repo.nodeDBbyNum.first() }
nodes[myNodeNum]?.let { node ->
if (cachedDeviceMetrics == null) {
cachedDeviceMetrics = node.deviceMetrics
}
if (cachedLocalStats == null) {
// Fallback to DB stats if repository hasn't received any fresh ones yet
cachedLocalStats = repo.localStats.value.takeIf { it.uptime_seconds != 0 }
}
}
}
}
val stats = cachedLocalStats
val metrics = cachedDeviceMetrics
val message =
when {
stats != null -> stats.formatToString(metrics?.battery_level)
metrics != null -> metrics.formatToString()
else -> null
}
// Only update cachedMessage if we have something new, otherwise keep what we have.
// Fallback to "No Stats Available" only if we truly have nothing.
if (message != null) {
cachedMessage = message
} else if (cachedMessage == null) {
cachedMessage = getString(Res.string.no_local_stats)
}
val notification =
createServiceStateNotification(
name = summaryString.orEmpty(),
message = cachedMessage,
nextUpdateAt = nextStatsUpdateMillis,
)
notificationManager.notify(SERVICE_NOTIFY_ID, notification)
return notification
}
override suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {
showConversationNotification(contactKey, isBroadcast, channelName, isSilent = isSilent)
}
override suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {
showConversationNotification(contactKey, isBroadcast, channelName, isSilent = isSilent)
}
override suspend fun updateWaypointNotification(
contactKey: String,
name: String,
message: String,
waypointId: Int,
isSilent: Boolean,
) {
val notification = createWaypointNotification(name, message, waypointId, isSilent)
notificationManager.notify(contactKey.hashCode(), notification)
}
private suspend fun showConversationNotification(
contactKey: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean = false,
) {
val ourNode = nodeRepository.value.ourNodeInfo.value
val history =
packetRepository.value
.getMessagesFrom(contactKey, includeFiltered = false) { nodeId ->
if (nodeId == DataPacket.ID_LOCAL) {
ourNode ?: nodeRepository.value.getNode(nodeId)
} else {
nodeRepository.value.getNode(nodeId ?: "")
}
}
.first()
val unread = history.filter { !it.read }
val displayHistory =
if (unread.size < MIN_CONTEXT_MESSAGES) {
history.take(MIN_CONTEXT_MESSAGES).reversed()
} else {
unread.take(MAX_HISTORY_MESSAGES).reversed()
}
if (displayHistory.isEmpty()) return
val notification =
createConversationNotification(
contactKey = contactKey,
isBroadcast = isBroadcast,
channelName = channelName,
history = displayHistory,
isSilent = isSilent,
)
notificationManager.notify(contactKey.hashCode(), notification)
showGroupSummary()
}
private fun showGroupSummary() {
val activeNotifications =
notificationManager.activeNotifications.filter {
it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES
}
val ourNode = nodeRepository.value.ourNodeInfo.value
val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
val me =
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
.build()
val messagingStyle =
NotificationCompat.MessagingStyle(me)
.setGroupConversation(true)
.setConversationTitle(getString(Res.string.meshtastic_app_name))
activeNotifications.forEach { sbn ->
val senderTitle = sbn.notification.extras.getCharSequence(Notification.EXTRA_TITLE)
val messageText = sbn.notification.extras.getCharSequence(Notification.EXTRA_TEXT)
val postTime = sbn.postTime
if (senderTitle != null && messageText != null) {
// For the summary, we're creating a generic Person for the sender from the active notification's title.
// We don't have the original Person object or its colors/ID, so we're just using the name.
val senderPerson = Person.Builder().setName(senderTitle).build()
messagingStyle.addMessage(messageText, postTime, senderPerson)
}
}
val summaryNotification =
commonBuilder(NotificationType.DirectMessage)
.setSmallIcon(org.meshtastic.app.R.drawable.app_icon)
.setStyle(messagingStyle)
.setGroup(GROUP_KEY_MESSAGES)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
notificationManager.notify(SUMMARY_ID, summaryNotification)
}
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
val notification = createAlertNotification(contactKey, name, alert)
// Use a consistent, unique ID for each alert source.
notificationManager.notify(name.hashCode(), notification)
}
override fun showNewNodeSeenNotification(node: Node) {
val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name, node.num)
notificationManager.notify(node.num, notification)
}
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {
val notification = createLowBatteryNotification(node, isRemote)
notificationManager.notify(node.num, notification)
}
override fun showClientNotification(clientNotification: ClientNotification) {
val notification =
createClientNotification(getString(Res.string.client_notification), clientNotification.message)
notificationManager.notify(clientNotification.toString().hashCode(), notification)
}
override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
override fun clearClientNotification(notification: ClientNotification) =
notificationManager.cancel(notification.toString().hashCode())
// endregion
// region Notification Creation
private fun createServiceStateNotification(name: String, message: String?, nextUpdateAt: Long?): Notification {
val builder =
commonBuilder(NotificationType.ServiceState)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
.setOngoing(true)
.setContentTitle(name)
.setShowWhen(true)
message?.let {
// First line of message is used for collapsed view, ensure it doesn't have a bullet
builder.setContentText(it.substringBefore("\n").removePrefix(BULLET))
builder.setStyle(NotificationCompat.BigTextStyle().bigText(it))
}
nextUpdateAt
?.takeIf { it > nowMillis }
?.let {
builder.setWhen(it)
builder.setUsesChronometer(true)
builder.setChronometerCountDown(true)
}
return builder.build()
}
@Suppress("LongMethod")
private fun createConversationNotification(
contactKey: String,
isBroadcast: Boolean,
channelName: String?,
history: List<Message>,
isSilent: Boolean = false,
): Notification {
val type = if (isBroadcast) NotificationType.BroadcastMessage else NotificationType.DirectMessage
val builder = commonBuilder(type, createOpenMessageIntent(contactKey))
if (isSilent) {
builder.setSilent(true)
}
val ourNode = nodeRepository.value.ourNodeInfo.value
val meName = ourNode?.user?.long_name ?: getString(Res.string.you)
val me =
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
.build()
val style =
NotificationCompat.MessagingStyle(me)
.setGroupConversation(channelName != null)
.setConversationTitle(channelName)
history.forEach { msg ->
// Use the node attached to the message directly to ensure correct identification
val person =
Person.Builder()
.setName(msg.node.user.long_name)
.setKey(msg.node.user.id)
.setIcon(createPersonIcon(msg.node.user.short_name, msg.node.colors.second, msg.node.colors.first))
.build()
val text =
msg.originalMessage?.let { original ->
"↩️ \"${original.node.user.short_name}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}"
} ?: msg.text
style.addMessage(text, msg.receivedTime, person)
// Add reactions as separate "messages" in history if they exist
msg.emojis.forEach { reaction ->
val reactorNode = nodeRepository.value.getNode(reaction.user.id)
val reactor =
Person.Builder()
.setName(reaction.user.long_name)
.setKey(reaction.user.id)
.setIcon(
createPersonIcon(
reaction.user.short_name,
reactorNode.colors.second,
reactorNode.colors.first,
),
)
.build()
style.addMessage(
"${reaction.emoji} to \"${msg.text.take(SNIPPET_LENGTH)}...\"",
reaction.timestamp,
reactor,
)
}
}
val lastMessage = history.last()
builder
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setStyle(style)
.setGroup(GROUP_KEY_MESSAGES)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setWhen(lastMessage.receivedTime)
.setShowWhen(true)
.addAction(createReplyAction(contactKey))
.addAction(createMarkAsReadAction(contactKey))
.addAction(
createReactionAction(
contactKey = contactKey,
packetId = lastMessage.packetId,
toId = lastMessage.node.user.id,
channelIndex = lastMessage.node.channel,
),
)
return builder.build()
}
private fun createWaypointNotification(
name: String,
message: String,
waypointId: Int,
isSilent: Boolean,
): Notification {
val person = Person.Builder().setName(name).build()
val style = NotificationCompat.MessagingStyle(person).addMessage(message, nowMillis, person)
val builder =
commonBuilder(NotificationType.Waypoint, createOpenWaypointIntent(waypointId))
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setStyle(style)
.setGroup(GROUP_KEY_MESSAGES)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setWhen(nowMillis)
.setShowWhen(true)
if (isSilent) {
builder.setSilent(true)
}
return builder.build()
}
private fun createAlertNotification(contactKey: String, name: String, alert: String): Notification {
val person = Person.Builder().setName(name).build()
val style = NotificationCompat.MessagingStyle(person).addMessage(alert, nowMillis, person)
return commonBuilder(NotificationType.Alert, createOpenMessageIntent(contactKey))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(Notification.CATEGORY_ALARM)
.setAutoCancel(true)
.setStyle(style)
.build()
}
private fun createNewNodeSeenNotification(name: String, message: String, nodeNum: Int): Notification {
val title = getString(Res.string.new_node_seen).format(name)
val builder =
commonBuilder(NotificationType.NewNode, createOpenNodeDetailIntent(nodeNum))
.setCategory(Notification.CATEGORY_STATUS)
.setAutoCancel(true)
.setContentTitle(title)
.setWhen(nowMillis)
.setShowWhen(true)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
return builder.build()
}
private fun createLowBatteryNotification(node: Node, isRemote: Boolean): Notification {
val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal
val title = getString(Res.string.low_battery_title).format(node.user.short_name)
val batteryLevel = node.deviceMetrics.battery_level ?: 0
val message = getString(Res.string.low_battery_message).format(node.user.long_name, batteryLevel)
return commonBuilder(type, createOpenNodeDetailIntent(node.num))
.setCategory(Notification.CATEGORY_STATUS)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setProgress(MAX_BATTERY_LEVEL, batteryLevel, false)
.setContentTitle(title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setWhen(nowMillis)
.setShowWhen(true)
.build()
}
private fun createClientNotification(name: String, message: String): Notification =
commonBuilder(NotificationType.Client)
.setCategory(Notification.CATEGORY_ERROR)
.setAutoCancel(true)
.setContentTitle(name)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.build()
// endregion
// region Helper/Builder Methods
private val openAppIntent: PendingIntent by lazy {
val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
private fun createOpenMessageIntent(contactKey: String): PendingIntent {
val deepLinkUri = "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()
val deepLinkIntent =
Intent(Intent.ACTION_VIEW, deepLinkUri, context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(deepLinkIntent)
getPendingIntent(contactKey.hashCode(), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
}
private fun createOpenWaypointIntent(waypointId: Int): PendingIntent {
val deepLinkUri = "$DEEP_LINK_BASE_URI/map?waypointId=$waypointId".toUri()
val deepLinkIntent =
Intent(Intent.ACTION_VIEW, deepLinkUri, context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(deepLinkIntent)
getPendingIntent(waypointId, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
}
private fun createOpenNodeDetailIntent(nodeNum: Int): PendingIntent {
val deepLinkUri = "$DEEP_LINK_BASE_URI/node?destNum=$nodeNum".toUri()
val deepLinkIntent =
Intent(Intent.ACTION_VIEW, deepLinkUri, context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(deepLinkIntent)
getPendingIntent(nodeNum, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
}
private fun createReplyAction(contactKey: String): NotificationCompat.Action {
val replyLabel = getString(Res.string.reply)
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build()
val replyIntent =
Intent(context, ReplyReceiver::class.java).apply {
action = ReplyReceiver.REPLY_ACTION
putExtra(ReplyReceiver.CONTACT_KEY, contactKey)
}
val replyPendingIntent =
PendingIntent.getBroadcast(
context,
contactKey.hashCode(),
replyIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, replyLabel, replyPendingIntent)
.addRemoteInput(remoteInput)
.build()
}
private fun createMarkAsReadAction(contactKey: String): NotificationCompat.Action {
val label = getString(Res.string.mark_as_read)
val intent =
Intent(context, MarkAsReadReceiver::class.java).apply {
action = MARK_AS_READ_ACTION
putExtra(MarkAsReadReceiver.CONTACT_KEY, contactKey)
}
val pendingIntent =
PendingIntent.getBroadcast(
context,
contactKey.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent).build()
}
private fun createReactionAction(
contactKey: String,
packetId: Int,
toId: String,
channelIndex: Int,
): NotificationCompat.Action {
val label = "👍"
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = REACT_ACTION
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
putExtra(ReactionReceiver.EXTRA_REPLY_ID, packetId)
putExtra(ReactionReceiver.EXTRA_TO_ID, toId)
putExtra(ReactionReceiver.EXTRA_CHANNEL_INDEX, channelIndex)
putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
}
val pendingIntent =
PendingIntent.getBroadcast(
context,
packetId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent).build()
}
private fun commonBuilder(
type: NotificationType,
contentIntent: PendingIntent? = null,
): NotificationCompat.Builder {
val smallIcon = org.meshtastic.app.R.drawable.app_icon
return NotificationCompat.Builder(context, type.channelId)
.setSmallIcon(smallIcon)
.setColor(NOTIFICATION_LIGHT_COLOR)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(contentIntent ?: openAppIntent)
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val bitmap = createBitmap(PERSON_ICON_SIZE, PERSON_ICON_SIZE)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Draw background circle
paint.color = backgroundColor
canvas.drawCircle(PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, paint)
// Draw initials
paint.color = foregroundColor
paint.textSize = PERSON_ICON_SIZE * PERSON_ICON_TEXT_SIZE_RATIO
paint.textAlign = Paint.Align.CENTER
val initial =
if (name.isNotEmpty()) {
val codePoint = name.codePointAt(0)
String(Character.toChars(codePoint)).uppercase()
} else {
"?"
}
val xPos = canvas.width / 2f
val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f)
canvas.drawText(initial, xPos, yPos, paint)
return IconCompat.createWithBitmap(bitmap)
}
// endregion
// region Extension Functions (Localized)
private fun LocalStats.formatToString(batteryLevel: Int? = null): String {
val parts = mutableListOf<String>()
batteryLevel?.let {
if (it > MAX_BATTERY_LEVEL) {
parts.add(BULLET + getString(Res.string.powered))
} else {
parts.add(BULLET + getString(Res.string.local_stats_battery, it))
}
}
parts.add(BULLET + getString(Res.string.local_stats_nodes, num_online_nodes, num_total_nodes))
parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(uptime_seconds)))
parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization, air_util_tx))
if (heap_free_bytes > 0 || heap_total_bytes > 0) {
parts.add(
BULLET +
getString(Res.string.local_stats_heap) +
": " +
getString(Res.string.local_stats_heap_value, heap_free_bytes, heap_total_bytes),
)
}
// Traffic Stats
if (num_packets_tx > 0 || num_packets_rx > 0) {
parts.add(BULLET + getString(Res.string.local_stats_traffic, num_packets_tx, num_packets_rx, num_rx_dupe))
}
if (num_tx_relay > 0) {
parts.add(BULLET + getString(Res.string.local_stats_relays, num_tx_relay, num_tx_relay_canceled))
}
// Diagnostic Fields
val diagnosticParts = mutableListOf<String>()
if (noise_floor != 0) diagnosticParts.add(getString(Res.string.local_stats_noise, noise_floor))
if (num_packets_rx_bad > 0) diagnosticParts.add(getString(Res.string.local_stats_bad, num_packets_rx_bad))
if (num_tx_dropped > 0) diagnosticParts.add(getString(Res.string.local_stats_dropped, num_tx_dropped))
if (diagnosticParts.isNotEmpty()) {
parts.add(
BULLET + getString(Res.string.local_stats_diagnostics_prefix, diagnosticParts.joinToString(" | ")),
)
}
return parts.joinToString("\n")
}
private fun DeviceMetrics.formatToString(): String {
val parts = mutableListOf<String>()
battery_level?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
uptime_seconds?.let { parts.add(BULLET + getString(Res.string.local_stats_uptime, formatUptime(it))) }
if (channel_utilization != null || air_util_tx != null) {
parts.add(
BULLET + getString(Res.string.local_stats_utilization, channel_utilization ?: 0f, air_util_tx ?: 0f),
)
}
return parts.joinToString("\n")
}
// endregion
}

View file

@ -1,61 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import android.app.ForegroundServiceStartNotAllowedException
import android.content.Context
import android.os.Build
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import co.touchlab.kermit.Logger
import org.meshtastic.app.BuildConfig
import org.meshtastic.app.worker.ServiceKeepAliveWorker
// / Helper function to start running our service
fun MeshService.Companion.startService(context: Context) {
// Bind to our service using the same mechanism an external client would use (for testing coverage)
// The following would work for us, but not external users:
// val intent = Intent(this, MeshService::class.java)
// intent.action = IMeshService::class.java.name
// Before binding we want to explicitly create - so the service stays alive forever (so it can keep
// listening for the bluetooth packets arriving from the radio. And when they arrive forward them
// to Signal or whatever.
Logger.i { "Trying to start service debug=${BuildConfig.DEBUG}" }
val intent = createIntent(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
context.startForegroundService(intent)
} catch (ex: ForegroundServiceStartNotAllowedException) {
Logger.w { "Unable to start service foreground: ${ex.message}. Scheduling fallback worker." }
scheduleKeepAliveWorker(context)
}
} else {
context.startForegroundService(intent)
}
}
private fun scheduleKeepAliveWorker(context: Context) {
val request =
OneTimeWorkRequestBuilder<ServiceKeepAliveWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context).enqueue(request)
}

View file

@ -1,67 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.ServiceRepository
class ReactionReceiver :
BroadcastReceiver(),
KoinComponent {
private val serviceRepository: ServiceRepository by inject()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != REACT_ACTION) return
val contactKey = intent.getStringExtra(EXTRA_CONTACT_KEY) ?: return
val reaction = intent.getStringExtra(EXTRA_EMOJI) ?: intent.getStringExtra(EXTRA_REACTION) ?: return
val replyId = intent.getIntExtra(EXTRA_REPLY_ID, intent.getIntExtra(EXTRA_PACKET_ID, 0))
scope.launch {
try {
serviceRepository.onServiceAction(ServiceAction.Reaction(reaction, replyId, contactKey))
} catch (e: Exception) {
Logger.e(e) { "Error sending reaction" }
}
}
}
companion object {
const val REACT_ACTION = "org.meshtastic.app.REACT_ACTION"
const val EXTRA_CONTACT_KEY = "extra_contact_key"
const val EXTRA_REACTION = "extra_reaction"
const val EXTRA_REPLY_ID = "extra_reply_id"
const val EXTRA_PACKET_ID = "extra_packet_id"
const val EXTRA_TO_ID = "extra_to_id"
const val EXTRA_CHANNEL_INDEX = "extra_channel_index"
const val EXTRA_EMOJI = "extra_emoji"
}
}

View file

@ -1,81 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MeshServiceNotifications
/**
* A [BroadcastReceiver] that handles inline replies from notifications.
*
* This receiver is triggered when a user replies to a message directly from a notification. It extracts the reply text
* and the contact key from the intent, sends the message using the [ServiceRepository], and then cancels the original
* notification.
*/
class ReplyReceiver :
BroadcastReceiver(),
KoinComponent {
private val radioController: RadioController by inject()
private val meshServiceNotifications: MeshServiceNotifications by inject()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
const val REPLY_ACTION = "org.meshtastic.app.REPLY_ACTION"
const val CONTACT_KEY = "contactKey"
const val KEY_TEXT_REPLY = "key_text_reply"
}
override fun onReceive(context: Context, intent: Intent) {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
if (remoteInput != null) {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: ""
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: ""
val pendingResult = goAsync()
scope.launch {
try {
sendMessage(message, contactKey)
meshServiceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()
}
}
}
}
private suspend fun sendMessage(str: String, contactKey: String) {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = contactKey.getOrNull(0)?.digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
val p = DataPacket(dest, channel ?: 0, str)
radioController.sendMessage(p)
}
}

View file

@ -1,162 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.service
import android.content.Context
import android.content.Intent
import android.os.Parcelable
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.ServiceRepository
import java.util.Locale
import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcasts
@Single
class ServiceBroadcasts(private val context: Context, private val serviceRepository: ServiceRepository) :
SharedServiceBroadcasts {
// A mapping of receiver class name to package name - used for explicit broadcasts
private val clientPackages = mutableMapOf<String, String>()
override fun subscribeReceiver(receiverName: String, packageName: String) {
clientPackages[receiverName] = packageName
}
/** Broadcast some received data Payload will be a DataPacket */
override fun broadcastReceivedData(dataPacket: DataPacket) {
val action = MeshService.actionReceived(dataPacket.dataType)
explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, dataPacket))
// Also broadcast with the numeric port number for backwards compatibility with some apps
val numericAction = actionReceived(dataPacket.dataType.toString())
if (numericAction != action) {
explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, dataPacket))
}
}
override fun broadcastNodeChange(node: Node) {
Logger.d { "Broadcasting node change ${node.user.toPIIString()}" }
val legacy = node.toLegacy()
val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, legacy)
explicitBroadcast(intent)
}
private fun Node.toLegacy(): NodeInfo = NodeInfo(
num = num,
user =
org.meshtastic.core.model.MeshUser(
id = user.id,
longName = user.long_name,
shortName = user.short_name,
hwModel = user.hw_model,
role = user.role.value,
),
position =
org.meshtastic.core.model
.Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view ?: 0,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits ?: 0,
)
.takeIf { latitude != 0.0 || longitude != 0.0 },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics =
org.meshtastic.core.model.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 = org.meshtastic.core.model.EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
hopsAway = hopsAway,
nodeStatus = nodeStatus,
)
fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) {
if (packetId == 0) {
Logger.d { "Ignoring anonymous packet status" }
} else {
// Do not log, contains PII possibly
// MeshService.Logger.d { "Broadcasting message status $p" }
val intent =
Intent(ACTION_MESSAGE_STATUS).apply {
putExtra(EXTRA_PACKET_ID, packetId)
putExtra(EXTRA_STATUS, status as Parcelable)
}
explicitBroadcast(intent)
}
}
/** Broadcast our current connection status */
override fun broadcastConnection() {
val connectionState = serviceRepository.connectionState.value
// ATAK expects a String: "CONNECTED" or "DISCONNECTED"
// It uses equalsIgnoreCase, but we'll use uppercase to be specific.
val stateStr = connectionState.toString().uppercase(Locale.ROOT)
val intent = Intent(ACTION_MESH_CONNECTED).apply { putExtra(EXTRA_CONNECTED, stateStr) }
explicitBroadcast(intent)
if (connectionState == ConnectionState.Disconnected) {
explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED))
}
// Restore legacy action for other consumers (e.g. mesh_service_example)
val legacyIntent =
Intent(ACTION_CONNECTION_CHANGED).apply {
putExtra(EXTRA_CONNECTED, stateStr)
// Legacy boolean extra often expected by older implementations
putExtra("connected", connectionState == ConnectionState.Connected)
}
explicitBroadcast(legacyIntent)
}
/**
* See com.geeksville.mesh broadcast intents.
*
* RECEIVED_OPAQUE for data received from other nodes
* NODE_CHANGE for new IDs appearing or disappearing
* ACTION_MESH_CONNECTED for losing/gaining connection to the packet radio
* Note: this is not the same as RadioInterfaceService.RADIO_CONNECTED_ACTION,
* because it implies we have assembled a valid node db.
*/
private fun explicitBroadcast(intent: Intent) {
context.sendBroadcast(
intent,
) // We also do a regular (not explicit broadcast) so any context-registered rceivers will work
clientPackages.forEach {
intent.setClassName(it.value, it.key)
context.sendBroadcast(intent)
}
}
}

View file

@ -74,7 +74,6 @@ import org.meshtastic.app.navigation.firmwareGraph
import org.meshtastic.app.navigation.mapGraph
import org.meshtastic.app.navigation.nodesGraph
import org.meshtastic.app.navigation.settingsGraph
import org.meshtastic.app.service.MeshService
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.DeviceVersion
@ -96,6 +95,7 @@ import org.meshtastic.core.resources.should_update
import org.meshtastic.core.resources.should_update_firmware
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.navigation.icon

View file

@ -1,64 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.worker
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import co.touchlab.kermit.Logger
import org.koin.android.annotation.KoinWorker
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
@KoinWorker
class MeshLogCleanupWorker(
appContext: Context,
workerParams: WorkerParameters,
private val meshLogRepository: MeshLogRepository,
private val meshLogPrefs: MeshLogPrefs,
) : CoroutineWorker(appContext, workerParams) {
@Suppress("TooGenericExceptionCaught")
override suspend fun doWork(): Result = try {
val retentionDays = meshLogPrefs.retentionDays.value
if (!meshLogPrefs.loggingEnabled.value) {
logger.i { "Skipping cleanup because mesh log storage is disabled" }
} else if (retentionDays == 0) {
logger.i { "Skipping cleanup because retention is set to never delete" }
} else {
val retentionLabel =
if (retentionDays == -1) {
"1 hour"
} else {
"$retentionDays days"
}
logger.d { "Cleaning logs older than $retentionLabel" }
meshLogRepository.deleteLogsOlderThan(retentionDays)
logger.i { "Successfully cleaned old MeshLog entries" }
}
Result.success()
} catch (e: Exception) {
logger.e(e) { "Failed to clean MeshLog entries" }
Result.failure()
}
companion object {
const val WORK_NAME = "meshlog_cleanup_worker"
}
private val logger = Logger.withTag(WORK_NAME)
}

View file

@ -1,90 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.worker
import android.app.Notification
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import co.touchlab.kermit.Logger
import org.koin.android.annotation.KoinWorker
import org.meshtastic.app.R
import org.meshtastic.app.service.MeshService
import org.meshtastic.app.service.startService
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
/**
* A worker whose sole purpose is to start the MeshService from the background. This is used as a fallback when
* `startForegroundService` is blocked by Android 14+ restrictions. It runs as an Expedited worker to gain temporary
* foreground start privileges.
*/
@KoinWorker
class ServiceKeepAliveWorker(
appContext: Context,
workerParams: WorkerParameters,
private val serviceNotifications: MeshServiceNotifications,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun getForegroundInfo(): ForegroundInfo {
// We use the same notification channel as the main service notification
// to minimize user disruption.
// On Android 12+, we need to provide a foreground info for expedited work.
val notification = createNotification()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
SERVICE_NOTIFY_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
ForegroundInfo(SERVICE_NOTIFY_ID, notification)
}
}
@Suppress("TooGenericExceptionCaught")
override suspend fun doWork(): Result {
Logger.i { "ServiceKeepAliveWorker: Attempting to start MeshService" }
return try {
MeshService.startService(applicationContext)
Result.success()
} catch (e: Exception) {
Logger.e(e) { "ServiceKeepAliveWorker failed to start service" }
Result.failure()
}
}
private fun createNotification(): Notification {
// We ensure channels are created
serviceNotifications.initChannels()
// We create a generic "Resuming" notification.
// We use "my_service" which matches NotificationType.ServiceState.channelId in MeshServiceNotificationsImpl
return NotificationCompat.Builder(applicationContext, "my_service")
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Resuming Mesh Service")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
}
}

Binary file not shown.