mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(widget): Add Local Stats glance widget (#4642)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
692ad78c80
commit
9970d31520
23 changed files with 1256 additions and 24 deletions
|
|
@ -17,12 +17,17 @@
|
|||
package com.geeksville.mesh
|
||||
|
||||
import android.app.Application
|
||||
import android.appwidget.AppWidgetProviderInfo
|
||||
import android.os.Build
|
||||
import androidx.collection.intSetOf
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.widget.LocalStatsWidgetReceiver
|
||||
import com.geeksville.mesh.worker.MeshLogCleanupWorker
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -31,8 +36,11 @@ import dagger.hilt.android.HiltAndroidApp
|
|||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
|
||||
import org.meshtastic.core.common.ContextServices
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
|
|
@ -40,6 +48,7 @@ import org.meshtastic.core.prefs.mesh.MeshPrefs
|
|||
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
/**
|
||||
|
|
@ -65,6 +74,45 @@ open class MeshUtilApplication :
|
|||
// Schedule periodic MeshLog cleanup
|
||||
scheduleMeshLogCleanup()
|
||||
|
||||
// Generate and publish widget preview for Android 15+ widget picker
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
applicationScope.launch {
|
||||
suspend fun pushPreview() {
|
||||
try {
|
||||
Logger.i { "Pushing generated widget preview..." }
|
||||
val result =
|
||||
GlanceAppWidgetManager(this@MeshUtilApplication)
|
||||
.setWidgetPreviews(
|
||||
LocalStatsWidgetReceiver::class,
|
||||
intSetOf(AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN),
|
||||
)
|
||||
Logger.i { "setWidgetPreviews result: $result" }
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "Failed to set widget preview" }
|
||||
}
|
||||
}
|
||||
|
||||
pushPreview()
|
||||
|
||||
val entryPoint =
|
||||
EntryPointAccessors.fromApplication(
|
||||
this@MeshUtilApplication,
|
||||
com.geeksville.mesh.widget.LocalStatsWidget.LocalStatsWidgetEntryPoint::class.java,
|
||||
)
|
||||
try {
|
||||
// Wait for real data for up to 30 seconds before pushing an updated preview
|
||||
withTimeout(30.seconds) {
|
||||
entryPoint.widgetStateProvider().state.first { it.showContent && it.nodeShortName != null }
|
||||
}
|
||||
|
||||
Logger.i { "Real node data acquired. Pushing updated widget preview." }
|
||||
pushPreview()
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Logger.i(e) { "Timed out waiting for real node data for widget preview." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB
|
||||
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||
applicationScope.launch { entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress) }
|
||||
|
|
|
|||
|
|
@ -17,17 +17,23 @@
|
|||
package com.geeksville.mesh.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.geeksville.mesh.widget.LocalStatsWidget
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.analytics.DataPair
|
||||
import org.meshtastic.core.analytics.platform.PlatformAnalytics
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
|
|
@ -61,6 +67,7 @@ import kotlin.time.DurationUnit
|
|||
class MeshConnectionManager
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val connectionStateHolder: ConnectionStateHandler,
|
||||
private val serviceBroadcasts: MeshServiceBroadcasts,
|
||||
|
|
@ -82,6 +89,7 @@ constructor(
|
|||
private var handshakeTimeout: Job? = null
|
||||
private var connectTimeMsec = 0L
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
|
||||
|
|
@ -89,6 +97,16 @@ constructor(
|
|||
// Ensure notification title and content stay in sync with state changes
|
||||
connectionStateHolder.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
|
||||
|
||||
// Kickstart the widget composition. The widget internally uses collectAsState()
|
||||
// and its own sampled StateFlow to drive updates automatically without excessive IPC and recreation.
|
||||
scope.launch {
|
||||
try {
|
||||
LocalStatsWidget().updateAll(context)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
|
||||
}
|
||||
}
|
||||
|
||||
nodeRepository.myNodeInfo
|
||||
.onEach { myNodeEntity ->
|
||||
locationRequestsJob?.cancel()
|
||||
|
|
@ -286,6 +304,7 @@ constructor(
|
|||
}
|
||||
|
||||
fun updateTelemetry(telemetry: Telemetry) {
|
||||
telemetry.local_stats?.let { nodeRepository.updateLocalStats(it) }
|
||||
updateStatusNotification(telemetry)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ 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
|
||||
|
|
@ -81,6 +83,7 @@ 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.core.service.MeshServiceNotifications
|
||||
|
|
@ -312,7 +315,10 @@ constructor(
|
|||
cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
|
||||
}
|
||||
if (cachedLocalStats == null) {
|
||||
cachedLocalStats = entity.deviceTelemetry.local_stats
|
||||
// Fallback to DB stats if repository hasn't received any fresh ones yet
|
||||
cachedLocalStats =
|
||||
repo.localStats.value.takeIf { it.uptime_seconds != 0 }
|
||||
?: entity.deviceTelemetry.local_stats
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -855,11 +861,26 @@ constructor(
|
|||
|
||||
private fun LocalStats.formatToString(batteryLevel: Int? = null): String {
|
||||
val parts = mutableListOf<String>()
|
||||
batteryLevel?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
|
||||
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))
|
||||
|
|
@ -887,7 +908,11 @@ constructor(
|
|||
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))) }
|
||||
parts.add(BULLET + getString(Res.string.local_stats_utilization, channel_utilization ?: 0f, air_util_tx ?: 0f))
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
|
|||
412
app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
Normal file
412
app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.widget
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.CircularProgressIndicator
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.LinearProgressIndicator
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.components.CircleIconButton
|
||||
import androidx.glance.appwidget.components.Scaffold
|
||||
import androidx.glance.appwidget.components.TitleBar
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.air_utilization
|
||||
import org.meshtastic.core.resources.battery
|
||||
import org.meshtastic.core.resources.channel_utilization
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.meshtastic_app_name
|
||||
import org.meshtastic.core.resources.nodes
|
||||
import org.meshtastic.core.resources.refresh
|
||||
import org.meshtastic.core.resources.updated
|
||||
import org.meshtastic.core.resources.uptime
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
|
||||
class LocalStatsWidget : GlanceAppWidget() {
|
||||
|
||||
override val sizeMode: SizeMode = SizeMode.Responsive(RESPONSIVE_SIZES)
|
||||
override val previewSizeMode: androidx.glance.appwidget.PreviewSizeMode = SizeMode.Responsive(RESPONSIVE_SIZES)
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface LocalStatsWidgetEntryPoint {
|
||||
fun widgetStateProvider(): LocalStatsWidgetStateProvider
|
||||
}
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val entryPoint =
|
||||
EntryPointAccessors.fromApplication(context.applicationContext, LocalStatsWidgetEntryPoint::class.java)
|
||||
val stateProvider = entryPoint.widgetStateProvider()
|
||||
|
||||
provideContent {
|
||||
val state by stateProvider.state.collectAsState()
|
||||
WidgetContent(state)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) {
|
||||
val entryPoint =
|
||||
EntryPointAccessors.fromApplication(context.applicationContext, LocalStatsWidgetEntryPoint::class.java)
|
||||
val stateProvider = entryPoint.widgetStateProvider()
|
||||
val currentState = stateProvider.state.value
|
||||
|
||||
val stateToRender =
|
||||
if (currentState.showContent && currentState.nodeShortName != null) {
|
||||
currentState
|
||||
} else {
|
||||
createMockWidgetState()
|
||||
}
|
||||
provideContent { WidgetContent(stateToRender) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun WidgetContent(state: LocalStatsWidgetUiState) {
|
||||
val context = LocalContext.current
|
||||
CompositionLocalProvider(
|
||||
androidx.compose.ui.platform.LocalContext provides context,
|
||||
LocalConfiguration provides context.resources.configuration,
|
||||
LocalDensity provides Density(context.resources.displayMetrics.density),
|
||||
) {
|
||||
GlanceTheme {
|
||||
Scaffold(
|
||||
titleBar = {
|
||||
TitleBar(
|
||||
startIcon = ImageProvider(com.geeksville.mesh.R.drawable.app_icon),
|
||||
title = state.appName,
|
||||
actions = {
|
||||
CircleIconButton(
|
||||
imageProvider = ImageProvider(com.geeksville.mesh.R.drawable.ic_refresh),
|
||||
contentDescription = state.refreshLabel,
|
||||
onClick = actionRunCallback<RefreshLocalStatsAction>(),
|
||||
backgroundColor = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier =
|
||||
GlanceModifier.fillMaxSize().clickable(actionStartActivity<com.geeksville.mesh.MainActivity>()),
|
||||
) {
|
||||
if (state.showContent) {
|
||||
FullStatsContent(state)
|
||||
} else {
|
||||
Disconnected(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FullStatsContent(state: LocalStatsWidgetUiState) {
|
||||
val size = LocalSize.current
|
||||
val isNarrow = size.width < 160.dp
|
||||
val isShort = size.height < 110.dp
|
||||
val isSmall = isNarrow || isShort
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
// Main Stats Container
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
// Summary Header: Node Chip + Battery
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
state.nodeShortName?.let { name ->
|
||||
state.nodeColors?.let { colors -> NodeChip(shortName = name, colors = colors) }
|
||||
}
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
StatRow(
|
||||
label = state.batteryLabel,
|
||||
value = state.batteryValue,
|
||||
progress = state.batteryProgress,
|
||||
isSmall = isSmall,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
|
||||
// Utilization Stats
|
||||
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
StatRow(
|
||||
label = state.channelUtilizationLabel,
|
||||
value = state.channelUtilizationValue,
|
||||
progress = state.channelUtilizationProgress,
|
||||
isSmall = isSmall,
|
||||
modifier = GlanceModifier.defaultWeight().padding(end = 4.dp),
|
||||
)
|
||||
StatRow(
|
||||
label = state.airUtilizationLabel,
|
||||
value = state.airUtilizationValue,
|
||||
progress = state.airUtilizationProgress,
|
||||
isSmall = isSmall,
|
||||
modifier = GlanceModifier.defaultWeight().padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Detailed Traffic/Relay Stats
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
state.trafficText?.let { StatText(it, isSmall) }
|
||||
state.relayText?.let { StatText(it, isSmall) }
|
||||
state.diagnosticsText?.let { StatText(it, isSmall) }
|
||||
state.heapText?.let {
|
||||
val heapProgress =
|
||||
if (state.heapTotalBytes > 0) {
|
||||
state.heapFreeBytes.toFloat() / state.heapTotalBytes
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
StatRow(it, state.heapValue, heapProgress, isSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer (Nodes + Uptime - Pinned to bottom)
|
||||
Footer(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatText(text: String, isSmall: Boolean) {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = if (isSmall) 9.sp else 10.sp),
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Disconnected(state: LocalStatsWidgetUiState) {
|
||||
Column(
|
||||
modifier = GlanceModifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (state.isConnecting) {
|
||||
CircularProgressIndicator(modifier = GlanceModifier.size(24.dp))
|
||||
} else {
|
||||
Image(
|
||||
provider = ImageProvider(com.geeksville.mesh.R.drawable.app_icon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = state.statusText,
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Footer(state: LocalStatsWidgetUiState) {
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(top = 2.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.Start) {
|
||||
Text(
|
||||
text = state.nodesLabel,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
|
||||
)
|
||||
Text(
|
||||
text = state.nodeCountText,
|
||||
maxLines = 1,
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = state.uptimeLabel,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
|
||||
)
|
||||
Text(
|
||||
text = state.uptimeText,
|
||||
maxLines = 1,
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val footerText =
|
||||
if (state.updatedLabel.isNotEmpty()) {
|
||||
"${state.updatedLabel} ${state.updatedText}"
|
||||
} else {
|
||||
state.updatedText
|
||||
}
|
||||
Text(
|
||||
text = footerText,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 8.sp),
|
||||
modifier = GlanceModifier.padding(bottom = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@Composable
|
||||
private fun NodeChip(shortName: String, colors: Pair<Int, Int>, modifier: GlanceModifier = GlanceModifier) {
|
||||
val (fg, bg) = colors
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.width(64.dp)
|
||||
.background(Color(bg))
|
||||
.cornerRadius(4.dp)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = shortName,
|
||||
style = TextStyle(color = ColorProvider(Color(fg)), fontSize = 11.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatRow(
|
||||
label: String,
|
||||
value: String?,
|
||||
progress: Float,
|
||||
isSmall: Boolean,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Column(modifier = modifier.padding(vertical = 2.dp)) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = label,
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = if (isSmall) 10.sp else 11.sp,
|
||||
),
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
)
|
||||
value?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = progress,
|
||||
modifier = GlanceModifier.fillMaxWidth().height(4.dp).cornerRadius(2.dp),
|
||||
color = GlanceTheme.colors.primary,
|
||||
backgroundColor = GlanceTheme.colors.surfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SMALL_SQUARE = DpSize(100.dp, 100.dp)
|
||||
private val HORIZONTAL_RECTANGLE = DpSize(250.dp, 100.dp)
|
||||
private val BIG_SQUARE = DpSize(250.dp, 250.dp)
|
||||
|
||||
private val RESPONSIVE_SIZES = setOf(SMALL_SQUARE, HORIZONTAL_RECTANGLE, BIG_SQUARE)
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun createMockWidgetState() = LocalStatsWidgetUiState(
|
||||
connectionState = ConnectionState.Connected,
|
||||
showContent = true,
|
||||
appName = getStringSuspend(Res.string.meshtastic_app_name),
|
||||
nodesLabel = getStringSuspend(Res.string.nodes),
|
||||
uptimeLabel = getStringSuspend(Res.string.uptime),
|
||||
updatedLabel = getStringSuspend(Res.string.updated),
|
||||
refreshLabel = getStringSuspend(Res.string.refresh),
|
||||
nodeShortName = "ME",
|
||||
nodeColors = 0xFFFFFFFF.toInt() to 0xFF000000.toInt(),
|
||||
batteryLabel = getStringSuspend(Res.string.battery),
|
||||
batteryValue = "85%",
|
||||
batteryProgress = 0.85f,
|
||||
channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
|
||||
channelUtilizationValue = "18.5%",
|
||||
channelUtilizationProgress = 0.185f,
|
||||
airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
|
||||
airUtilizationValue = "3.2%",
|
||||
airUtilizationProgress = 0.032f,
|
||||
trafficText = "TX: 145 | RX: 892 | D: 42",
|
||||
nodeCountText = "2/3",
|
||||
uptimeText = "2d 0h",
|
||||
updatedText = "5m ago",
|
||||
)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.widget
|
||||
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class LocalStatsWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = LocalStatsWidget()
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.widget
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import org.meshtastic.core.common.util.DateFormatter
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.util.formatUptime
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.air_utilization
|
||||
import org.meshtastic.core.resources.battery
|
||||
import org.meshtastic.core.resources.channel_utilization
|
||||
import org.meshtastic.core.resources.connecting
|
||||
import org.meshtastic.core.resources.device_sleeping
|
||||
import org.meshtastic.core.resources.disconnected
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.local_stats_bad
|
||||
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_noise
|
||||
import org.meshtastic.core.resources.local_stats_relays
|
||||
import org.meshtastic.core.resources.local_stats_traffic
|
||||
import org.meshtastic.core.resources.local_stats_updated_at
|
||||
import org.meshtastic.core.resources.meshtastic_app_name
|
||||
import org.meshtastic.core.resources.nodes
|
||||
import org.meshtastic.core.resources.powered
|
||||
import org.meshtastic.core.resources.refresh
|
||||
import org.meshtastic.core.resources.updated
|
||||
import org.meshtastic.core.resources.uptime
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class LocalStatsWidgetUiState(
|
||||
val connectionState: ConnectionState = ConnectionState.Disconnected,
|
||||
// Rendering data
|
||||
val statusText: String = "",
|
||||
val isConnecting: Boolean = false,
|
||||
val showContent: Boolean = false,
|
||||
|
||||
// Static Strings (Resolved in provider for Glance stability)
|
||||
val appName: String = "",
|
||||
val nodesLabel: String = "",
|
||||
val uptimeLabel: String = "",
|
||||
val updatedLabel: String = "",
|
||||
val refreshLabel: String = "",
|
||||
|
||||
// Node Identity
|
||||
val nodeShortName: String? = null,
|
||||
val nodeColors: Pair<Int, Int>? = null,
|
||||
|
||||
// Battery
|
||||
val batteryLabel: String = "",
|
||||
val batteryValue: String = "",
|
||||
val batteryProgress: Float = 0f,
|
||||
|
||||
// Utilization
|
||||
val channelUtilizationLabel: String = "",
|
||||
val channelUtilizationValue: String = "",
|
||||
val channelUtilizationProgress: Float = 0f,
|
||||
val airUtilizationLabel: String = "",
|
||||
val airUtilizationValue: String = "",
|
||||
val airUtilizationProgress: Float = 0f,
|
||||
|
||||
// Packet Stats Lines
|
||||
val trafficText: String? = null,
|
||||
val relayText: String? = null,
|
||||
val diagnosticsText: String? = null,
|
||||
val heapFreeBytes: Int = 0,
|
||||
val heapTotalBytes: Int = 0,
|
||||
val heapValue: String? = null,
|
||||
val heapText: String? = null,
|
||||
|
||||
// Footer
|
||||
val nodeCountText: String = "",
|
||||
val uptimeText: String = "",
|
||||
val updatedText: String = "",
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class LocalStatsWidgetStateProvider
|
||||
@Inject
|
||||
constructor(
|
||||
nodeRepository: NodeRepository,
|
||||
serviceRepository: ServiceRepository,
|
||||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
val state: StateFlow<LocalStatsWidgetUiState> =
|
||||
combine(
|
||||
serviceRepository.connectionState,
|
||||
nodeRepository.nodeDBbyNum
|
||||
.map { nodes ->
|
||||
val online = nodes.values.count { it.lastHeard > onlineTimeThreshold() }
|
||||
nodes.size to online
|
||||
}
|
||||
.distinctUntilChanged(),
|
||||
nodeRepository.localStats,
|
||||
nodeRepository.ourNodeInfo,
|
||||
) { connectionState, (totalNodes, onlineNodes), stats, localNode ->
|
||||
StateInput(connectionState, totalNodes, onlineNodes, stats, localNode)
|
||||
}
|
||||
.map { input ->
|
||||
mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = LocalStatsWidgetUiState(),
|
||||
)
|
||||
|
||||
private data class StateInput(
|
||||
val connectionState: ConnectionState,
|
||||
val totalNodes: Int,
|
||||
val onlineNodes: Int,
|
||||
val stats: LocalStats,
|
||||
val localNode: Node?,
|
||||
)
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
private suspend fun mapToUiState(
|
||||
connectionState: ConnectionState,
|
||||
totalNodes: Int,
|
||||
onlineNodes: Int,
|
||||
stats: LocalStats,
|
||||
localNode: Node?,
|
||||
): LocalStatsWidgetUiState {
|
||||
val statusText =
|
||||
when (connectionState) {
|
||||
is ConnectionState.Disconnected -> getStringSuspend(Res.string.disconnected)
|
||||
is ConnectionState.Connecting -> getStringSuspend(Res.string.connecting)
|
||||
is ConnectionState.DeviceSleep -> getStringSuspend(Res.string.device_sleeping)
|
||||
is ConnectionState.Connected -> ""
|
||||
}
|
||||
|
||||
val metrics = localNode?.deviceMetrics
|
||||
val batteryLevel = metrics?.battery_level ?: 0
|
||||
val isPowered = batteryLevel > 100
|
||||
val batteryValue = if (isPowered) getStringSuspend(Res.string.powered) else "$batteryLevel%"
|
||||
|
||||
val hasStats = stats.uptime_seconds != 0
|
||||
val channelUtil = if (hasStats) stats.channel_utilization else metrics?.channel_utilization ?: 0f
|
||||
val airUtilTx = if (hasStats) stats.air_util_tx else metrics?.air_util_tx ?: 0f
|
||||
|
||||
val diag = mutableListOf<String>()
|
||||
if (hasStats) {
|
||||
if (stats.noise_floor != 0) {
|
||||
diag.add(getStringSuspend(Res.string.local_stats_noise, stats.noise_floor))
|
||||
}
|
||||
if (stats.num_packets_rx_bad > 0) {
|
||||
diag.add(getStringSuspend(Res.string.local_stats_bad, stats.num_packets_rx_bad))
|
||||
}
|
||||
if (stats.num_tx_dropped > 0) {
|
||||
diag.add(getStringSuspend(Res.string.local_stats_dropped, stats.num_tx_dropped))
|
||||
}
|
||||
}
|
||||
|
||||
val uptimeSecs = if (hasStats) stats.uptime_seconds.toLong() else metrics?.uptime_seconds?.toLong() ?: 0L
|
||||
|
||||
return LocalStatsWidgetUiState(
|
||||
connectionState = connectionState,
|
||||
statusText = statusText,
|
||||
isConnecting = connectionState is ConnectionState.Connecting,
|
||||
showContent = connectionState is ConnectionState.Connected,
|
||||
appName = getStringSuspend(Res.string.meshtastic_app_name),
|
||||
nodesLabel = getStringSuspend(Res.string.nodes),
|
||||
uptimeLabel = getStringSuspend(Res.string.uptime),
|
||||
updatedLabel = getStringSuspend(Res.string.updated),
|
||||
refreshLabel = getStringSuspend(Res.string.refresh),
|
||||
nodeShortName = localNode?.user?.short_name,
|
||||
nodeColors = localNode?.colors,
|
||||
batteryLabel = getStringSuspend(Res.string.battery),
|
||||
batteryValue = batteryValue,
|
||||
batteryProgress = (batteryLevel / 100f).coerceIn(0f, 1f),
|
||||
channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
|
||||
channelUtilizationValue = "%.1f%%".format(channelUtil),
|
||||
channelUtilizationProgress = (channelUtil / 100f).coerceIn(0f, 1f),
|
||||
airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
|
||||
airUtilizationValue = "%.1f%%".format(airUtilTx),
|
||||
airUtilizationProgress = (airUtilTx / 100f).coerceIn(0f, 1f),
|
||||
trafficText =
|
||||
if (hasStats) {
|
||||
getStringSuspend(
|
||||
Res.string.local_stats_traffic,
|
||||
stats.num_packets_tx,
|
||||
stats.num_packets_rx,
|
||||
stats.num_rx_dupe,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
relayText =
|
||||
stats
|
||||
.takeIf { hasStats && (it.num_tx_relay > 0 || it.num_tx_relay_canceled > 0) }
|
||||
?.let {
|
||||
getStringSuspend(Res.string.local_stats_relays, it.num_tx_relay, it.num_tx_relay_canceled)
|
||||
},
|
||||
diagnosticsText =
|
||||
if (diag.isNotEmpty()) {
|
||||
getStringSuspend(Res.string.local_stats_diagnostics_prefix, diag.joinToString(" | "))
|
||||
} else {
|
||||
null
|
||||
},
|
||||
heapFreeBytes = if (hasStats) stats.heap_free_bytes else 0,
|
||||
heapTotalBytes = if (hasStats) stats.heap_total_bytes else 0,
|
||||
heapValue =
|
||||
if (hasStats) {
|
||||
getStringSuspend(Res.string.local_stats_heap_value, stats.heap_free_bytes, stats.heap_total_bytes)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
heapText = if (hasStats) getStringSuspend(Res.string.local_stats_heap) else null,
|
||||
nodeCountText = "$onlineNodes/$totalNodes",
|
||||
uptimeText = formatUptime(uptimeSecs.toInt()),
|
||||
updatedText = getStringSuspend(Res.string.local_stats_updated_at, DateFormatter.formatShortDate(nowMillis)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import com.geeksville.mesh.service.MeshCommandSender
|
||||
import com.geeksville.mesh.service.MeshNodeManager
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
|
||||
class RefreshLocalStatsAction : ActionCallback {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface RefreshLocalStatsEntryPoint {
|
||||
fun commandSender(): MeshCommandSender
|
||||
|
||||
fun nodeManager(): MeshNodeManager
|
||||
}
|
||||
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val entryPoint =
|
||||
EntryPointAccessors.fromApplication(context.applicationContext, RefreshLocalStatsEntryPoint::class.java)
|
||||
val commandSender = entryPoint.commandSender()
|
||||
val nodeManager = entryPoint.nodeManager()
|
||||
|
||||
val myNodeNum = nodeManager.myNodeNum ?: return
|
||||
|
||||
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
|
||||
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue