Skip to content

Commit cc6114b

Browse files
jamesarichCopilotgarthvh
authored
fix(widget): drive updates via debounced state observer (#5185)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
1 parent 7a21d9c commit cc6114b

4 files changed

Lines changed: 53 additions & 13 deletions

File tree

feature/widget/src/main/kotlin/org/meshtastic/feature/widget/AndroidAppWidgetUpdater.kt

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,48 @@
1717
package org.meshtastic.feature.widget
1818

1919
import android.content.Context
20+
import androidx.glance.appwidget.GlanceAppWidgetManager
2021
import androidx.glance.appwidget.updateAll
2122
import co.touchlab.kermit.Logger
23+
import kotlinx.coroutines.CoroutineScope
24+
import kotlinx.coroutines.Dispatchers
25+
import kotlinx.coroutines.FlowPreview
26+
import kotlinx.coroutines.SupervisorJob
27+
import kotlinx.coroutines.flow.debounce
28+
import kotlinx.coroutines.flow.distinctUntilChanged
29+
import kotlinx.coroutines.launch
2230
import org.koin.core.annotation.Single
2331
import org.meshtastic.core.repository.AppWidgetUpdater
2432

33+
private const val WIDGET_UPDATE_DEBOUNCE_MS = 500L
34+
2535
@Single
26-
class AndroidAppWidgetUpdater(private val context: Context) : AppWidgetUpdater {
36+
class AndroidAppWidgetUpdater(private val context: Context, stateProvider: LocalStatsWidgetStateProvider) :
37+
AppWidgetUpdater {
38+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
39+
40+
init {
41+
// Observe state changes and trigger a widget re-render whenever the data changes.
42+
// Glance compositions are ephemeral — the widget cannot self-update via collectAsState()
43+
// alone, so we must call updateAll() externally to drive re-renders.
44+
@OptIn(FlowPreview::class)
45+
scope.launch {
46+
stateProvider.state
47+
.debounce(WIDGET_UPDATE_DEBOUNCE_MS)
48+
.distinctUntilChanged { old, new -> old.copy(updateTimeMillis = 0) == new.copy(updateTimeMillis = 0) }
49+
.collect { if (hasWidgetInstances()) updateAll() }
50+
}
51+
}
52+
53+
private suspend fun hasWidgetInstances(): Boolean =
54+
GlanceAppWidgetManager(context).getGlanceIds(LocalStatsWidget::class.java).isNotEmpty()
55+
2756
override suspend fun updateAll() {
28-
// Kickstart the widget composition.
29-
// The widget internally uses collectAsState() and its own sampled StateFlow
30-
// to drive updates automatically without excessive IPC and recreation.
3157
@Suppress("TooGenericExceptionCaught")
3258
try {
3359
LocalStatsWidget().updateAll(context)
3460
} catch (e: Exception) {
35-
co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" }
61+
Logger.e(e) { "Failed to update widgets" }
3662
}
3763
}
3864
}

feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,6 @@ data class LocalStatsWidgetUiState(
7676
val updateTimeMillis: Long = 0,
7777
)
7878

79-
private const val WIDGET_SUBSCRIPTION_TIMEOUT_MS = 5_000L
80-
8179
@Single
8280
class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) {
8381
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -100,12 +98,7 @@ class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepos
10098
.map { input ->
10199
mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode)
102100
}
103-
.distinctUntilChanged()
104-
.stateIn(
105-
scope = scope,
106-
started = SharingStarted.WhileSubscribed(WIDGET_SUBSCRIPTION_TIMEOUT_MS),
107-
initialValue = LocalStatsWidgetUiState(),
108-
)
101+
.stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = LocalStatsWidgetUiState())
109102

110103
private data class StateInput(
111104
val connectionState: ConnectionState,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright (c) 2025-2026 Meshtastic LLC
4+
~
5+
~ This program is free software: you can redistribute it and/or modify
6+
~ it under the terms of the GNU General Public License as published by
7+
~ the Free Software Foundation, either version 3 of the License, or
8+
~ (at your option) any later version.
9+
~
10+
~ This program is distributed in the hope that it will be useful,
11+
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
~ GNU General Public License for more details.
14+
~
15+
~ You should have received a copy of the GNU General Public License
16+
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
-->
18+
<resources>
19+
<string name="widget_local_stats_label">Meshtastic</string>
20+
</resources>

feature/widget/src/main/res/xml/widget_local_stats_info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
-->
1818
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
19+
android:label="@string/widget_local_stats_label"
1920
android:initialLayout="@layout/glance_default_loading_layout"
2021
android:previewLayout="@layout/widget_local_stats_preview"
2122
android:minWidth="110dp"

0 commit comments

Comments
 (0)