From 38c50799cbd984b7c4c280f5268a22eee6b23faf Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 7 Oct 2025 19:13:49 -0500
Subject: [PATCH] fix: service status notification refactor (#3386)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../geeksville/mesh/service/MeshService.kt | 112 ++++++------------
.../mesh/service/MeshServiceNotifications.kt | 98 ++++++++++-----
core/strings/src/main/res/values/strings.xml | 1 +
3 files changed, 109 insertions(+), 102 deletions(-)
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index 4a56d43d5..38224c036 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -18,6 +18,7 @@
package com.geeksville.mesh.service
import android.annotation.SuppressLint
+import android.app.Notification
import android.app.Service
import android.content.Context
import android.content.Intent
@@ -45,7 +46,6 @@ import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.StoreAndForwardProtos
import com.geeksville.mesh.TelemetryProtos
-import com.geeksville.mesh.TelemetryProtos.LocalStats
import com.geeksville.mesh.XmodemProtos
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.copy
@@ -207,9 +207,6 @@ class MeshService : Service() {
private var configNonce = 1
}
- private var previousSummary: String? = null
- private var previousStats: LocalStats? = null
-
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
@@ -228,22 +225,6 @@ class MeshService : Service() {
return name ?: getString(R.string.unknown_username)
}
- private val notificationSummary
- get() =
- when (connectionStateHolder.getState()) {
- ConnectionState.CONNECTED -> getString(R.string.connected_count).format(numOnlineNodes)
-
- ConnectionState.DISCONNECTED -> getString(R.string.disconnected)
- ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping)
- }
-
- private var localStatsTelemetry: TelemetryProtos.Telemetry? = null
- private val localStats: LocalStats?
- get() = localStatsTelemetry?.localStats
-
- private val localStatsUpdatedAtMillis: Long?
- get() = localStatsTelemetry?.time?.let { it * 1000L }
-
/** start our location requests (if they weren't already running) */
private fun startLocationRequests() {
// If we're already observing updates, don't register again
@@ -368,12 +349,7 @@ class MeshService : Service() {
val wantForeground = a != null && a != NO_DEVICE_SELECTED
Timber.i("Requesting foreground service=$wantForeground")
-
- // We always start foreground because that's how our service is always started (if we didn't
- // then android would
- // kill us)
- // but if we don't really need foreground we immediately stop it.
- val notification = serviceNotifications.updateServiceStateNotification(notificationSummary)
+ val notification = updateServiceStatusNotification()
try {
ServiceCompat.startForeground(
@@ -976,35 +952,34 @@ class MeshService : Service() {
}
// Update our DB of users based on someone sending out a Telemetry subpacket
- private fun handleReceivedTelemetry(fromNum: Int, t: TelemetryProtos.Telemetry) {
+ private fun handleReceivedTelemetry(fromNum: Int, telemetry: TelemetryProtos.Telemetry) {
val isRemote = (fromNum != myNodeNum)
- if (!isRemote && t.hasLocalStats()) {
- localStatsTelemetry = t
- maybeUpdateServiceStatusNotification()
+ if (!isRemote) {
+ updateServiceStatusNotification(telemetry = telemetry)
}
- updateNodeInfo(fromNum) {
+ updateNodeInfo(fromNum) { nodeEntity ->
when {
- t.hasDeviceMetrics() -> {
- it.deviceTelemetry = t
- if (fromNum == myNodeNum || (isRemote && it.isFavorite)) {
+ telemetry.hasDeviceMetrics() -> {
+ nodeEntity.deviceTelemetry = telemetry
+ if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) {
if (
- t.deviceMetrics.voltage > batteryPercentUnsupported &&
- t.deviceMetrics.batteryLevel <= batteryPercentLowThreshold
+ telemetry.deviceMetrics.voltage > batteryPercentUnsupported &&
+ telemetry.deviceMetrics.batteryLevel <= batteryPercentLowThreshold
) {
- if (shouldBatteryNotificationShow(fromNum, t)) {
- serviceNotifications.showOrUpdateLowBatteryNotification(it, isRemote)
+ if (shouldBatteryNotificationShow(fromNum, telemetry)) {
+ serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote)
}
} else {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
}
- serviceNotifications.cancelLowBatteryNotification(it)
+ serviceNotifications.cancelLowBatteryNotification(nodeEntity)
}
}
}
- t.hasEnvironmentMetrics() -> it.environmentTelemetry = t
- t.hasPowerMetrics() -> it.powerTelemetry = t
+ telemetry.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetry
+ telemetry.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetry
}
}
}
@@ -1095,7 +1070,6 @@ class MeshService : Service() {
}
.build(),
)
- onNodeDBChanged()
} else {
Timber.w("Ignoring early received packet: ${packet.toOneLineString()}")
// earlyReceivedPackets.add(packet)
@@ -1228,11 +1202,6 @@ class MeshService : Service() {
private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt()
- // If we just changed our nodedb, we might want to do somethings
- private fun onNodeDBChanged() {
- maybeUpdateServiceStatusNotification()
- }
-
/** Send in analytics about mesh connection */
private fun reportConnection() {
val radioModel = DataPair("radio_model", myNodeInfo?.model ?: "unknown")
@@ -1337,30 +1306,21 @@ class MeshService : Service() {
ConnectionState.DISCONNECTED -> startDisconnect()
}
- // Update the android notification in the status bar
- maybeUpdateServiceStatusNotification()
+ updateServiceStatusNotification()
}
- private fun maybeUpdateServiceStatusNotification() {
- var update = false
- val currentSummary = notificationSummary
- val currentStats = localStats
- val currentStatsUpdatedAtMillis = localStatsUpdatedAtMillis
- if (currentSummary.isNotBlank() && (previousSummary == null || !previousSummary.equals(currentSummary))) {
- previousSummary = currentSummary
- update = true
- }
- if (currentStats != null && (previousStats == null || !(previousStats?.equals(currentStats) ?: false))) {
- previousStats = currentStats
- update = true
- }
- if (update) {
- serviceNotifications.updateServiceStateNotification(
- summaryString = currentSummary,
- localStats = currentStats,
- currentStatsUpdatedAtMillis = currentStatsUpdatedAtMillis,
- )
- }
+ private fun updateServiceStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification {
+ val notificationSummary =
+ when (connectionStateHolder.getState()) {
+ ConnectionState.CONNECTED -> getString(R.string.connected_count).format(numOnlineNodes)
+
+ ConnectionState.DISCONNECTED -> getString(R.string.disconnected)
+ ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping)
+ }
+ return serviceNotifications.updateServiceStateNotification(
+ summaryString = notificationSummary,
+ telemetry = telemetry,
+ )
}
private fun onRadioConnectionState(newState: ConnectionState) {
@@ -1573,7 +1533,7 @@ class MeshService : Service() {
* Regenerate the myNodeInfo model. We call this twice. Once after we receive myNodeInfo from the device and again
* after we have the node DB (which might allow us a better notion of our HwModel.
*/
- private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata) {
+ private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata? = MeshProtos.DeviceMetadata.getDefaultInstance()) {
val myInfo = rawMyNodeInfo
if (myInfo != null) {
val mi =
@@ -1581,25 +1541,27 @@ class MeshService : Service() {
MyNodeEntity(
myNodeNum = myNodeNum,
model =
- when (val hwModel = metadata.hwModel) {
+ when (val hwModel = metadata?.hwModel) {
null,
MeshProtos.HardwareModel.UNSET,
-> null
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
},
- firmwareVersion = metadata.firmwareVersion,
+ firmwareVersion = metadata?.firmwareVersion,
couldUpdate = false,
shouldUpdate = false, // TODO add check after re-implementing firmware updates
currentPacketId = currentPacketId and 0xffffffffL,
messageTimeoutMsec = 5 * 60 * 1000, // constants from current firmware code
minAppVersion = minAppVersion,
maxChannels = 8,
- hasWifi = metadata.hasWifi,
+ hasWifi = metadata?.hasWifi == true,
deviceId = deviceId.toStringUtf8(),
)
}
- serviceScope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
+ if (metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()) {
+ serviceScope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
+ }
newMyNodeInfo = mi
}
}
@@ -1626,6 +1588,7 @@ class MeshService : Service() {
insertMeshLog(packetToSave)
rawMyNodeInfo = myInfo
+ regenMyNodeInfo()
// We'll need to get a new set of channels and settings now
serviceScope.handledLaunch {
@@ -1809,7 +1772,6 @@ class MeshService : Service() {
haveNodeDB = true // we now have nodes from real hardware
sendAnalytics()
onHasSettings()
- onNodeDBChanged()
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt
index ea0206730..8eee47248 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt
@@ -37,6 +37,7 @@ import androidx.core.net.toUri
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R.raw
+import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.TelemetryProtos.LocalStats
import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import org.meshtastic.core.database.entity.NodeEntity
@@ -164,6 +165,7 @@ class MeshServiceNotifications(private val context: Context) {
NotificationType.ServiceState -> {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
+
NotificationType.DirectMessage,
NotificationType.BroadcastMessage,
NotificationType.NewNode,
@@ -180,6 +182,7 @@ class MeshServiceNotifications(private val context: Context) {
)
if (type == NotificationType.LowBatteryRemote) enableVibration(true)
}
+
NotificationType.Alert -> {
setShowBadge(true)
enableLights(true)
@@ -194,6 +197,7 @@ class MeshServiceNotifications(private val context: Context) {
.build(),
)
}
+
NotificationType.Client -> {
setShowBadge(true)
}
@@ -202,17 +206,44 @@ class MeshServiceNotifications(private val context: Context) {
notificationManager.createNotificationChannel(channel)
}
+ var cachedTelemetry: TelemetryProtos.Telemetry? = null
+ var cachedLocalStats: LocalStats? = null
+ var nextStatsUpdateMillis: Long = 0
+ var cachedMessage: String? = null
+
// region Public Notification Methods
fun updateServiceStateNotification(
summaryString: String?,
- localStats: LocalStats? = null,
- currentStatsUpdatedAtMillis: Long? = System.currentTimeMillis(),
+ telemetry: TelemetryProtos.Telemetry? = cachedTelemetry,
): Notification {
+ val hasLocalStats = telemetry?.hasLocalStats() == true
+ val hasDeviceMetrics = telemetry?.hasDeviceMetrics() == true
+ val message =
+ if (hasLocalStats) {
+ val localStats = telemetry.localStats
+ val localStatsMessage = localStats?.formatToString()
+ cachedTelemetry = telemetry
+ nextStatsUpdateMillis = System.currentTimeMillis() + FIFTEEN_MINUTES_IN_MILLIS
+ localStatsMessage
+ } else if (cachedTelemetry == null && hasDeviceMetrics) {
+ val deviceMetrics = telemetry.deviceMetrics
+ val deviceMetricsMessage = deviceMetrics.formatToString()
+ if (cachedLocalStats == null) {
+ cachedTelemetry = telemetry
+ }
+ nextStatsUpdateMillis = System.currentTimeMillis()
+ deviceMetricsMessage
+ } else {
+ null
+ }
+
+ cachedMessage = message ?: cachedMessage ?: context.getString(R.string.no_local_stats)
+
val notification =
createServiceStateNotification(
name = summaryString.orEmpty(),
- message = localStats.formatToString(),
- nextUpdateAt = currentStatsUpdatedAtMillis?.plus(FIFTEEN_MINUTES_IN_MILLIS),
+ message = cachedMessage,
+ nextUpdateAt = nextStatsUpdateMillis,
)
notificationManager.notify(SERVICE_NOTIFY_ID, notification)
return notification
@@ -270,11 +301,13 @@ class MeshServiceNotifications(private val context: Context) {
builder.setStyle(NotificationCompat.BigTextStyle().bigText(it))
}
- nextUpdateAt?.let {
- builder.setWhen(it)
- builder.setUsesChronometer(true)
- builder.setChronometerCountDown(true)
- }
+ nextUpdateAt
+ ?.takeIf { it > System.currentTimeMillis() }
+ ?.let {
+ builder.setWhen(it)
+ builder.setUsesChronometer(true)
+ builder.setChronometerCountDown(true)
+ }
return builder.build()
}
@@ -427,23 +460,34 @@ class MeshServiceNotifications(private val context: Context) {
}
// Extension function to format LocalStats into a readable string.
-private fun LocalStats?.formatToString(): String {
- if (this == null) return "No Local Stats"
-
- return this.allFields
- .mapNotNull { (k, v) ->
- when (k.name) {
- "num_online_nodes",
- "num_total_nodes",
- -> null // Exclude these fields
- "uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}"
- "channel_utilization" -> "ChUtil: %.2f%%".format(v)
- "air_util_tx" -> "AirUtilTX: %.2f%%".format(v)
- else -> {
- val formattedKey = k.name.replace('_', ' ').replaceFirstChar { it.titlecase() }
- "$formattedKey: $v"
- }
+private fun LocalStats?.formatToString(): String? = this?.allFields
+ ?.mapNotNull { (k, v) ->
+ when (k.name) {
+ "num_online_nodes",
+ "num_total_nodes",
+ -> null // Exclude these fields
+ "uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}"
+ "channel_utilization" -> "ChUtil: %.2f%%".format(v)
+ "air_util_tx" -> "AirUtilTX: %.2f%%".format(v)
+ else -> {
+ val formattedKey = k.name.replace('_', ' ').replaceFirstChar { it.titlecase() }
+ "$formattedKey: $v"
}
}
- .joinToString("\n")
-}
+ }
+ ?.joinToString("\n")
+
+private fun TelemetryProtos.DeviceMetrics?.formatToString(): String? = this?.allFields
+ ?.mapNotNull { (k, v) ->
+ when (k.name) {
+ "battery_level" -> "Battery Level: $v"
+ "uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}"
+ "channel_utilization" -> "ChUtil: %.2f%%".format(v)
+ "air_util_tx" -> "AirUtilTX: %.2f%%".format(v)
+ else -> {
+ val formattedKey = k.name.replace('_', ' ').replaceFirstChar { it.titlecase() }
+ "$formattedKey: $v"
+ }
+ }
+ }
+ ?.joinToString("\n")
diff --git a/core/strings/src/main/res/values/strings.xml b/core/strings/src/main/res/values/strings.xml
index d75ff8c8d..95a98006d 100644
--- a/core/strings/src/main/res/values/strings.xml
+++ b/core/strings/src/main/res/values/strings.xml
@@ -928,4 +928,5 @@
%1$d dBm
No application available to handle link.
System Settings
+ No Stats Available