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