fix: service status notification refactor (#3386)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-10-07 19:13:49 -05:00 committed by GitHub
parent da65dfdd15
commit 38c50799cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 109 additions and 102 deletions

View file

@ -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()
}
}

View file

@ -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")