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
|
|
@ -243,6 +243,9 @@ dependencies {
|
|||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.ui.text)
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.appwidget.preview)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
|
|
@ -268,6 +271,7 @@ dependencies {
|
|||
implementation(libs.nordic.common.ui)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.glance.preview)
|
||||
|
||||
googleImplementation(libs.location.services)
|
||||
googleImplementation(libs.play.services.maps)
|
||||
|
|
@ -293,6 +297,7 @@ dependencies {
|
|||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
testImplementation(libs.androidx.test.ext.junit)
|
||||
testImplementation(libs.androidx.glance.appwidget)
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
|
|
|
|||
|
|
@ -241,6 +241,17 @@
|
|||
<receiver android:name="com.geeksville.mesh.service.MarkAsReadReceiver"/>
|
||||
<receiver android:name="com.geeksville.mesh.service.ReactionReceiver"/>
|
||||
|
||||
<receiver
|
||||
android:name="com.geeksville.mesh.widget.LocalStatsWidgetReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/local_stats_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- allow for plugin discovery -->
|
||||
<activity
|
||||
android:name="com.atakmap.app.component"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
27
app/src/main/res/drawable/ic_refresh.xml
Normal file
27
app/src/main/res/drawable/ic_refresh.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!--
|
||||
~ Copyright (c) 2025 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/>.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
||||
42
app/src/main/res/layout/widget_local_stats_preview.xml
Normal file
42
app/src/main/res/layout/widget_local_stats_preview.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2025 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/>.
|
||||
-->
|
||||
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#2E2E2E"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
android:gravity="center">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_launcher_foreground"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Meshtastic Stats"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textStyle="bold"
|
||||
android:textSize="14sp" />
|
||||
|
||||
</LinearLayout>
|
||||
25
app/src/main/res/xml/local_stats_widget_info.xml
Normal file
25
app/src/main/res/xml/local_stats_widget_info.xml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2025 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/>.
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:previewLayout="@layout/widget_local_stats_preview"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen" />
|
||||
|
|
@ -16,6 +16,9 @@
|
|||
*/
|
||||
package com.geeksville.mesh.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
|
|
@ -36,17 +39,20 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
|
|||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.prefs.ui.UiPrefs
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.ToRadio
|
||||
|
||||
class MeshConnectionManagerTest {
|
||||
|
||||
private val context: Context = mockk(relaxed = true)
|
||||
private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true)
|
||||
private val connectionStateHolder = ConnectionStateHandler()
|
||||
private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true)
|
||||
|
|
@ -72,16 +78,21 @@ class MeshConnectionManagerTest {
|
|||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
|
||||
mockkStatic("androidx.glance.appwidget.GlanceAppWidgetKt")
|
||||
coEvery { org.jetbrains.compose.resources.getString(any()) } returns "Mocked String"
|
||||
coEvery { org.jetbrains.compose.resources.getString(any(), *anyVararg()) } returns "Mocked String"
|
||||
coEvery { any<GlanceAppWidget>().updateAll(any()) } returns Unit
|
||||
|
||||
every { radioInterfaceService.connectionState } returns radioConnectionState
|
||||
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
|
||||
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
|
||||
every { nodeRepository.myNodeInfo } returns MutableStateFlow<MyNodeEntity?>(null)
|
||||
every { nodeRepository.ourNodeInfo } returns MutableStateFlow<Node?>(null)
|
||||
every { nodeRepository.localStats } returns MutableStateFlow(LocalStats())
|
||||
|
||||
manager =
|
||||
MeshConnectionManager(
|
||||
context,
|
||||
radioInterfaceService,
|
||||
connectionStateHolder,
|
||||
serviceBroadcasts,
|
||||
|
|
@ -102,6 +113,7 @@ class MeshConnectionManagerTest {
|
|||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
|
||||
unmockkStatic("androidx.glance.appwidget.GlanceAppWidgetKt")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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 com.geeksville.mesh.widget
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import org.meshtastic.proto.User
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Config(sdk = [34])
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LocalStatsWidgetStateProviderTest {
|
||||
|
||||
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
private val nodeDbFlow = MutableStateFlow<Map<Int, Node>>(emptyMap())
|
||||
private val localStatsFlow = MutableStateFlow(LocalStats())
|
||||
private val ourNodeInfoFlow = MutableStateFlow<Node?>(null)
|
||||
|
||||
private val serviceRepository = mockk<ServiceRepository>(relaxed = true)
|
||||
private val nodeRepository = mockk<NodeRepository>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic("org.meshtastic.core.resources.ContextExtKt")
|
||||
mockkStatic("org.meshtastic.core.model.util.TimeUtilsKt")
|
||||
|
||||
coEvery { getStringSuspend(any()) } returns "Mock String"
|
||||
coEvery { getStringSuspend(any(), *anyVararg()) } returns "Mock Formatted String"
|
||||
every { onlineTimeThreshold() } returns 0
|
||||
|
||||
// Explicitly return flows from mocks
|
||||
every { serviceRepository.connectionState } returns connectionStateFlow
|
||||
every { nodeRepository.nodeDBbyNum } returns nodeDbFlow
|
||||
every { nodeRepository.localStats } returns localStatsFlow
|
||||
every { nodeRepository.ourNodeInfo } returns ourNodeInfoFlow
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state reflects disconnected status`() = runTest {
|
||||
val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
|
||||
val state = provider.state.first()
|
||||
assertEquals(ConnectionState.Disconnected, state.connectionState)
|
||||
assertFalse(state.showContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connected state shows content and maps node info`() = runTest {
|
||||
connectionStateFlow.value = ConnectionState.Connected
|
||||
ourNodeInfoFlow.value =
|
||||
Node(
|
||||
num = 123,
|
||||
user = User(short_name = "ABC"),
|
||||
deviceMetrics = DeviceMetrics(battery_level = 85, channel_utilization = 12.5f),
|
||||
)
|
||||
|
||||
val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
|
||||
val state =
|
||||
provider.state.first { (it.connectionState == ConnectionState.Connected) && (it.nodeShortName == "ABC") }
|
||||
|
||||
assertTrue(state.showContent)
|
||||
assertEquals("ABC", state.nodeShortName)
|
||||
assertEquals("85%", state.batteryValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `node count and update timestamp are populated`() = runTest {
|
||||
connectionStateFlow.value = ConnectionState.Connected
|
||||
nodeDbFlow.value = mapOf(1 to Node(num = 1, lastHeard = 1000))
|
||||
|
||||
val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
|
||||
val state = provider.state.first { it.nodeCountText == "1/1" }
|
||||
|
||||
assertEquals("1/1", state.nodeCountText)
|
||||
assertEquals("Mock Formatted String", state.updatedText)
|
||||
}
|
||||
}
|
||||
|
|
@ -42,11 +42,13 @@ import org.meshtastic.core.database.entity.MyNodeEntity
|
|||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.database.model.NodeSortOption
|
||||
import org.meshtastic.core.datastore.LocalStatsDataSource
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.di.ProcessLifecycle
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import org.meshtastic.proto.User
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -57,10 +59,11 @@ import javax.inject.Singleton
|
|||
class NodeRepository
|
||||
@Inject
|
||||
constructor(
|
||||
@ProcessLifecycle processLifecycle: Lifecycle,
|
||||
@ProcessLifecycle private val processLifecycle: Lifecycle,
|
||||
private val nodeInfoReadDataSource: NodeInfoReadDataSource,
|
||||
private val nodeInfoWriteDataSource: NodeInfoWriteDataSource,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val localStatsDataSource: LocalStatsDataSource,
|
||||
) {
|
||||
/** Hardware info about our local device (can be null if not connected). */
|
||||
val myNodeInfo: StateFlow<MyNodeEntity?> =
|
||||
|
|
@ -81,6 +84,19 @@ constructor(
|
|||
val myId: StateFlow<String?>
|
||||
get() = _myId
|
||||
|
||||
/** The latest local stats telemetry received from the locally connected node. */
|
||||
val localStats: StateFlow<LocalStats> =
|
||||
localStatsDataSource.localStatsFlow.stateIn(
|
||||
processLifecycle.coroutineScope,
|
||||
SharingStarted.Eagerly,
|
||||
LocalStats(),
|
||||
)
|
||||
|
||||
/** Update the cached local stats telemetry. */
|
||||
fun updateLocalStats(stats: LocalStats) {
|
||||
processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) }
|
||||
}
|
||||
|
||||
/** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */
|
||||
val nodeDBbyNum: StateFlow<Map<Int, Node>> =
|
||||
nodeInfoReadDataSource
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
|
|||
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.datastore.LocalStatsDataSource
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
|
@ -49,6 +50,7 @@ class NodeRepositoryTest {
|
|||
private val writeDataSource: NodeInfoWriteDataSource = mockk(relaxed = true)
|
||||
private val lifecycle: Lifecycle = mockk(relaxed = true)
|
||||
private val lifecycleScope: LifecycleCoroutineScope = mockk()
|
||||
private val localStatsDataSource: LocalStatsDataSource = mockk(relaxed = true)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
|
||||
|
|
@ -88,7 +90,8 @@ class NodeRepositoryTest {
|
|||
val myNodeNum = 12345
|
||||
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
|
||||
|
||||
val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers)
|
||||
val repository =
|
||||
NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
|
||||
|
|
@ -102,7 +105,8 @@ class NodeRepositoryTest {
|
|||
val remoteNodeNum = 67890
|
||||
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
|
||||
|
||||
val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers)
|
||||
val repository =
|
||||
NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
|
||||
|
|
@ -117,7 +121,8 @@ class NodeRepositoryTest {
|
|||
val targetNodeNum = 111
|
||||
|
||||
myNodeInfoFlow.value = createMyNodeEntity(firstNodeNum)
|
||||
val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers)
|
||||
val repository =
|
||||
NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
// Initially should be mapped to LOCAL because it matches
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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.datastore
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import okio.IOException
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Class that handles saving and retrieving [LocalStats] data. */
|
||||
@Singleton
|
||||
class LocalStatsDataSource @Inject constructor(private val localStatsStore: DataStore<LocalStats>) {
|
||||
val localStatsFlow: Flow<LocalStats> =
|
||||
localStatsStore.data.catch { exception ->
|
||||
if (exception is IOException) {
|
||||
Logger.e { "Error reading LocalStats: ${exception.message}" }
|
||||
emit(LocalStats())
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setLocalStats(stats: LocalStats) {
|
||||
localStatsStore.updateData { stats }
|
||||
}
|
||||
|
||||
suspend fun clearLocalStats() {
|
||||
localStatsStore.updateData { LocalStats() }
|
||||
}
|
||||
}
|
||||
|
|
@ -43,10 +43,12 @@ import org.meshtastic.core.datastore.KEY_SHOW_IGNORED
|
|||
import org.meshtastic.core.datastore.KEY_THEME
|
||||
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
|
||||
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
|
||||
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -129,4 +131,16 @@ object DataStoreModule {
|
|||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideLocalStatsDataStore(
|
||||
@ApplicationContext appContext: Context,
|
||||
@DataStoreScope scope: CoroutineScope,
|
||||
): DataStore<LocalStats> = DataStoreFactory.create(
|
||||
serializer = LocalStatsSerializer,
|
||||
produceFile = { appContext.dataStoreFile("local_stats.pb") },
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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.datastore.serializer
|
||||
|
||||
import androidx.datastore.core.CorruptionException
|
||||
import androidx.datastore.core.Serializer
|
||||
import okio.IOException
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/** Serializer for the [LocalStats] object defined in telemetry.proto. */
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
object LocalStatsSerializer : Serializer<LocalStats> {
|
||||
override val defaultValue: LocalStats = LocalStats()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): LocalStats {
|
||||
try {
|
||||
return LocalStats.ADAPTER.decode(input)
|
||||
} catch (exception: IOException) {
|
||||
throw CorruptionException("Cannot read proto.", exception)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: LocalStats, output: OutputStream) = LocalStats.ADAPTER.encode(output, t)
|
||||
}
|
||||
|
|
@ -18,23 +18,36 @@ package org.meshtastic.core.resources
|
|||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.getString as composeGetString
|
||||
|
||||
/** Retrieves a string from the [StringResource] in a blocking manner. Use primarily in non-composable code. */
|
||||
fun getString(stringResource: StringResource): String = runBlocking {
|
||||
org.jetbrains.compose.resources.getString(stringResource)
|
||||
}
|
||||
fun getString(stringResource: StringResource): String = runBlocking { composeGetString(stringResource) }
|
||||
|
||||
/** Retrieves a formatted string from the [StringResource] in a blocking manner. */
|
||||
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
|
||||
val resolvedArgs =
|
||||
formatArgs.map { arg ->
|
||||
if (arg is StringResource) {
|
||||
getString(arg)
|
||||
} else {
|
||||
arg
|
||||
}
|
||||
}
|
||||
@Suppress("SpreadOperator")
|
||||
org.jetbrains.compose.resources.getString(stringResource, *resolvedArgs.toTypedArray())
|
||||
getStringSuspend(stringResource, *formatArgs)
|
||||
}
|
||||
|
||||
/** Retrieves a string from the [StringResource] in a suspending manner. */
|
||||
suspend fun getStringSuspend(stringResource: StringResource): String = composeGetString(stringResource)
|
||||
|
||||
/** Retrieves a formatted string from the [StringResource] in a suspending manner. */
|
||||
suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs: Any): String {
|
||||
val resolvedArgs =
|
||||
formatArgs
|
||||
.map { arg ->
|
||||
if (arg is StringResource) {
|
||||
// Resolve nested StringResources recursively
|
||||
getStringSuspend(arg)
|
||||
} else {
|
||||
arg
|
||||
}
|
||||
}
|
||||
.toTypedArray()
|
||||
|
||||
// Compose Multiplatform doesn't fully support complex formatting like %.2f
|
||||
// Fetch the raw string and format it using standard Java String.format.
|
||||
val rawString = composeGetString(stringResource)
|
||||
@Suppress("SpreadOperator")
|
||||
return String.format(java.util.Locale.getDefault(), rawString, *resolvedArgs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1199,10 +1199,17 @@
|
|||
<string name="local_stats_nodes">Nodes: %1$d online / %2$d total</string>
|
||||
<string name="local_stats_uptime">Uptime: %1$s</string>
|
||||
<string name="local_stats_utilization">ChUtil: %1$.2f%% | AirTX: %2$.2f%%</string>
|
||||
<string name="local_stats_traffic">Traffic: TX %1$d / RX %2$d (Dupes: %3$d)</string>
|
||||
<string name="local_stats_traffic">Traffic: TX %1$d / RX %2$d (D: %3$d)</string>
|
||||
<string name="local_stats_relays">Relays: %1$d (Canceled: %2$d)</string>
|
||||
<string name="local_stats_diagnostics_prefix">Diagnostics: %1$s</string>
|
||||
<string name="local_stats_noise">Noise %1$d dBm</string>
|
||||
<string name="local_stats_bad">Bad %1$d</string>
|
||||
<string name="local_stats_dropped">Dropped %1$d</string>
|
||||
<string name="local_stats_heap">Heap</string>
|
||||
<string name="local_stats_heap_value">%1$d / %2$d</string>
|
||||
<string name="local_stats_updated_at">%1$s</string>
|
||||
<string name="powered">Powered</string>
|
||||
<string name="meshtastic_stats">Meshtastic Stats</string>
|
||||
<string name="refresh">Refresh</string>
|
||||
<string name="updated">Updated</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
package org.meshtastic.feature.messaging.ui.contact
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -43,6 +43,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavHostController
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -93,7 +94,16 @@ fun AdaptiveContactsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
|
||||
PredictiveBackHandler(
|
||||
enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
|
||||
) { progress ->
|
||||
try {
|
||||
progress.collect { /* Predictive back progress could be used here to drive UI if scaffold supported it */ }
|
||||
handleBack()
|
||||
} catch (_: CancellationException) {
|
||||
// Gesture cancelled
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(initialContactKey) {
|
||||
if (initialContactKey != null) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ androidxComposeMaterial3Adaptive = "1.2.0"
|
|||
androidxHilt = "1.3.0"
|
||||
androidxTracing = "1.10.3"
|
||||
datastore = "1.2.0"
|
||||
glance = "1.2.0-rc01"
|
||||
lifecycle = "2.10.0"
|
||||
navigation = "2.9.7"
|
||||
navigation3 = "1.0.1"
|
||||
|
|
@ -77,6 +78,10 @@ androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", versi
|
|||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||
androidx-emoji2-emojipicker = { module = "androidx.emoji2:emoji2-emojipicker", version = "1.6.0" }
|
||||
androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
|
||||
androidx-glance-appwidget-preview = { module = "androidx.glance:glance-appwidget-preview", version.ref = "glance" }
|
||||
androidx-glance-preview = { module = "androidx.glance:glance-preview", version.ref = "glance" }
|
||||
androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" }
|
||||
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHilt" }
|
||||
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
|
||||
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidxHilt" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue