Skip to content

Commit 8b1c1b5

Browse files
psjostromclaude
andauthored
fix: refresh foreground notification every minute, not only on stale flips (#223)
* fix: refresh foreground notification every minute, not only on stale flips The stale-check loop previously only called updateNotification() when the stale-state flipped or workout mode was active: if (isStale != wasStale || workoutCountdownActive || workoutModeOn) { updateNotification() } This left the notification's "Xm" since-text (and the IOB / alert-pause / workout-elapsed countdowns rendered in composeReadingText) frozen at whatever value they had at the moment of the last reading. The most visible symptom: when CamAPS stops posting fresh values (sensor disconnect, "Attempting" reposts that the parser correctly drops), the notification keeps showing "0m" against the last good value until either a new reading arrives or the 10-min stale threshold trips. The user gets a false sense that data is fresh when it isn't. Drop the gate — call updateNotification() on every tick (every 60 s). The work is one DB read + one IOB calc + one notification rebuild; cheap enough at this cadence and the only path that keeps the displayed counters honest. The original gate's stated reason (avoid rebuilding during stable readings) doesn't justify the silent-stale display in a medical app. Also drop the now-unused `wasStale` field. Verified on device: with no new readings for >1 min, the notification "Xm" advances correctly each minute. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: refresh widget every minute alongside notification, add render-test The 60s tick in startStaleCheckLoop was rebuilding the foreground notification but not the home-screen widget. The widget's "Xm" / "now" freshness label is computed from System.currentTimeMillis() - reading.ts (StrimmaWidget.kt:120) but updateWidgets() was only called from onNewReading — between readings the widget froze at whatever counter value it had at the last reading-arrival, mirroring the notification bug that #223 fixes. Same medical-safety class (a glanceable surface lying about freshness). Adds NotificationHelperRenderTest pinning the rendering contract that the loop depends on: each call to buildNotification produces a tv_delta text reflecting the current elapsed minutes from reading.ts (0m / 5m / 9m). If a future refactor breaks the rendering math or someone re-introduces a gate around the per-tick updateNotification call, this catches the rendering-side half of the regression. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 486438b commit 8b1c1b5

2 files changed

Lines changed: 82 additions & 14 deletions

File tree

app/src/main/java/com/psjostrom/strimma/service/StrimmaService.kt

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,6 @@ class StrimmaService : Service() {
104104
private var followerJob: Job? = null
105105
private var lluFollowerJob: Job? = null
106106
private var calendarPollJob: Job? = null
107-
@Volatile
108-
private var wasStale = true // Start as stale (no data yet)
109107
private lateinit var workoutTriggerMinutes: StateFlow<Int>
110108

111109
private lateinit var predMinutes: StateFlow<Int>
@@ -269,17 +267,17 @@ class StrimmaService : Service() {
269267
val latest = dao.latestOnce()
270268
alertManager.checkStale(latest?.ts)
271269

272-
val isStale = AlertManager.isStale(latest?.ts)
273-
val workoutCountdownActive =
274-
WorkoutAlarmReceiver.notificationTriggerFired.get() && calendarPoller.nextEvent.value != null
275-
// Refresh notification once per minute during workout mode so the
276-
// "Workout 0:42" elapsed counter actually advances. Without this the
277-
// counter is computed once on state-transition and lies for the rest
278-
// of the session unless a new CGM reading arrives.
279-
val workoutModeOn = workoutModeManager.state.value is WorkoutMode.On
280-
if (isStale != wasStale || workoutCountdownActive || workoutModeOn) {
281-
updateNotification()
282-
}
270+
// Always rebuild the foreground notification AND the home-screen widget
271+
// each tick. Both surfaces show time-driven counters that need wall-clock
272+
// recomputation every minute: the notification's "Xm" since-text + IOB
273+
// countdown + alert-pause countdown + workout-mode "0:42" elapsed, and the
274+
// widget's "Xm" / "now" freshness label. The previous gate (refresh only on
275+
// stale-state transitions or workout mode) left these counters frozen at
276+
// whatever value they had at the moment of the last reading — most visibly,
277+
// both surfaces would show "0m" continuously until either the 10-min stale
278+
// threshold tripped or the next reading arrived.
279+
updateNotification()
280+
updateWidgets()
283281

284282
calendarPoller.nextEvent.value?.let { event ->
285283
if (System.currentTimeMillis() > event.startTime + WORKOUT_GRACE_MINUTES * MS_PER_MINUTE) {
@@ -448,7 +446,6 @@ class StrimmaService : Service() {
448446
val latest = dao.latestOnce()?.takeUnless { reading ->
449447
AlertManager.isStale(reading.ts)
450448
}
451-
wasStale = (latest == null)
452449
val graphWindowMs = notifGraphMinutes.value.toLong() * MS_PER_MINUTE
453450
val now = System.currentTimeMillis()
454451
val recent = dao.since(now - graphWindowMs)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.psjostrom.strimma.notification
2+
3+
import android.content.Context
4+
import android.widget.FrameLayout
5+
import android.widget.TextView
6+
import androidx.test.core.app.ApplicationProvider
7+
import com.psjostrom.strimma.R
8+
import com.psjostrom.strimma.data.GlucoseReading
9+
import org.junit.Assert.assertTrue
10+
import org.junit.Test
11+
import org.junit.runner.RunWith
12+
import org.robolectric.RobolectricTestRunner
13+
14+
/**
15+
* Pins the rendering contract that `StrimmaService.startStaleCheckLoop` depends on:
16+
* each call to `updateNotification` must produce a since-text that reflects the current
17+
* wall-clock elapsed time from the reading. If a future refactor re-introduces a gate
18+
* around the per-tick `updateNotification()` call (see PR #223), the user-visible "Xm"
19+
* counter would freeze between readings — these tests guard against that by verifying
20+
* the rendering side responds to changing elapsed time.
21+
*/
22+
@RunWith(RobolectricTestRunner::class)
23+
class NotificationHelperRenderTest {
24+
25+
private fun helper(): NotificationHelper {
26+
val context: Context = ApplicationProvider.getApplicationContext()
27+
return NotificationHelper(context).also { it.createChannel() }
28+
}
29+
30+
private fun renderedDeltaText(reading: GlucoseReading): String {
31+
val context: Context = ApplicationProvider.getApplicationContext()
32+
val notif = helper().buildNotification(
33+
reading = reading,
34+
recentReadings = listOf(reading),
35+
bgLow = 70.0,
36+
bgHigh = 180.0,
37+
)
38+
val rv = notif.bigContentView ?: notif.contentView
39+
?: error("notification has no custom content view")
40+
val mounted = rv.apply(context, FrameLayout(context))
41+
return mounted.findViewById<TextView>(R.id.tv_delta).text.toString()
42+
}
43+
44+
@Test
45+
fun `since-text shows 0m for a fresh reading`() {
46+
val now = System.currentTimeMillis()
47+
val text = renderedDeltaText(reading(ts = now))
48+
assertTrue("expected '0m' in '$text'", text.contains("0m"))
49+
}
50+
51+
@Test
52+
fun `since-text shows 5m for a five-minute-old reading`() {
53+
val now = System.currentTimeMillis()
54+
val text = renderedDeltaText(reading(ts = now - 5L * MS_PER_MINUTE))
55+
assertTrue("expected '5m' in '$text'", text.contains("5m"))
56+
}
57+
58+
@Test
59+
fun `since-text shows 9m for a nine-minute-old reading`() {
60+
val now = System.currentTimeMillis()
61+
val text = renderedDeltaText(reading(ts = now - 9L * MS_PER_MINUTE))
62+
assertTrue("expected '9m' in '$text'", text.contains("9m"))
63+
}
64+
65+
private fun reading(ts: Long): GlucoseReading =
66+
GlucoseReading(ts = ts, sgv = 100, direction = "Flat", delta = 0.0)
67+
68+
private companion object {
69+
const val MS_PER_MINUTE = 60_000L
70+
}
71+
}

0 commit comments

Comments
 (0)