mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(core:service): Move Android Service implementations to core:service
This commit is contained in:
parent
7593d2b0a4
commit
965def06b2
25 changed files with 57 additions and 50 deletions
|
|
@ -52,4 +52,9 @@ data class DeviceVersion(val asString: String) : Comparable<DeviceVersion> {
|
|||
}
|
||||
|
||||
override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt)
|
||||
|
||||
companion object {
|
||||
const val MIN_FW_VERSION = "2.5.14"
|
||||
const val ABS_MIN_FW_VERSION = "2.3.15"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
core/resources/src/androidMain/res/raw/alert.mp3
Normal file
BIN
core/resources/src/androidMain/res/raw/alert.mp3
Normal file
Binary file not shown.
|
|
@ -36,6 +36,7 @@ kotlin {
|
|||
implementation(projects.core.data)
|
||||
implementation(projects.core.database)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.prefs)
|
||||
implementation(projects.core.proto)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.service.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -200,7 +200,7 @@ class AndroidRadioControllerImpl(
|
|||
// Ensure service is running/restarted to handle the new address
|
||||
val intent =
|
||||
android.content.Intent().apply {
|
||||
setClassName("com.geeksville.mesh", "org.meshtastic.app.service.MeshService")
|
||||
setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService")
|
||||
}
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.content.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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.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
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.content.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.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.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.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(DeviceVersion.MIN_FW_VERSION)
|
||||
val absoluteMinDeviceVersion = DeviceVersion(DeviceVersion.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 != "n"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,912 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.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.core.resources.R.raw
|
||||
import org.meshtastic.core.service.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
|
||||
import org.meshtastic.core.service.ReactionReceiver.Companion.REACT_ACTION
|
||||
import org.meshtastic.core.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(context.applicationInfo.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, Class.forName("org.meshtastic.app.MainActivity")).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, Class.forName("org.meshtastic.app.MainActivity")).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, Class.forName("org.meshtastic.app.MainActivity")).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, Class.forName("org.meshtastic.app.MainActivity")).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 = context.applicationInfo.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
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.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.core.service.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=${false}" }
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.content.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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.content.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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.content.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service.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)
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service.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_"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service.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.core.service.MeshService
|
||||
import org.meshtastic.core.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(applicationContext.applicationInfo.icon)
|
||||
.setContentTitle("Resuming Mesh Service")
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue