mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: service extraction (#4828)
This commit is contained in:
parent
0d0bdf9172
commit
807db83f53
76 changed files with 309 additions and 257 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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_"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
Loading…
Add table
Add a link
Reference in a new issue