Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ Create `docs/whatsnew/X.Y.Z-{LOCALE}` files for all supported locales. Each file

Only include changes that are visible or relevant to the user of the app itself. Exclude anything related to CI/CD workflows, GitHub Actions, GitHub Pages, the website, internal tooling, or other infrastructure — users don't see these.

**Translation note:** Keep English concise (max 300 chars) so translations don't exceed GitHub's release notes limits (500 chars per language). Asian languages are more compact; Romance/Germanic languages expand.
**Translation note:** Keep English concise (max 300 chars) as a starting point, but the 300/500 numbers are guidance for the *English draft* only, not a guarantee for the rest. Translation length varies a lot by language (Romance/Germanic languages tend to expand, CJK languages tend to compress) — a translation that started from an in-limit English draft can still end up over the limit.

**Mandatory per-locale check — do this for every locale, not just English:** after writing (or translating) each `docs/whatsnew/X.Y.Z-{LOCALE}` file, check that specific file's character count (e.g. `wc -m`, not `wc -c`, since `wc -c` undercounts multi-byte UTF-8 locales like `ar`/`hi-IN`/`ja-JP`/`zh-CN`). No locale file may exceed 500 characters — Google Play's hard "what's new" limit is per-locale, not per-release. If any file is over, re-trim that locale's text (summarize/combine bullets) and re-check — do not assume it's fine because the English source was short.

**4b. Update `app/build.gradle.kts`**

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [1.2.5] - 2026-07-25

### Added

- Alert-enabled profiles are now monitored in the background for as long as Android Auto is connected, not only the profile currently shown on screen; a profile that has alerts enabled but isn't the active tab now still gets checked and can still alert

### Fixed

- Closed a narrow race where switching profiles right as an in-flight fetch for the previous profile resolved could show that profile's reading (and fire its alert) under the new profile's name; the fetch-generation guard is now bumped synchronously the moment a switch starts instead of only once the new fetch's coroutine runs
- Glucose alerts now appear on the Android Auto car screen instead of only the phone's notification tray: notifications are posted through `CarNotificationManager` and the app now declares the `notification` capability it requires

## [1.2.4] - 2026-07-23

### Security
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ android {
applicationId = "de.autosugar"
minSdk = 26
targetSdk = 36
versionCode = 9
versionName = "1.2.4"
versionCode = 10
versionName = "1.2.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down
32 changes: 30 additions & 2 deletions app/src/main/java/de/autosugar/car/AutoSugarSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,42 @@ package de.autosugar.car
import android.content.Intent
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.lifecycle.lifecycleScope
import de.autosugar.data.repository.NightscoutRepository
import de.autosugar.data.storage.AppPreferencesDataStore
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

class AutoSugarSession(
private val repository: NightscoutRepository,
private val appPrefs: AppPreferencesDataStore,
) : Session() {

override fun onCreateScreen(intent: Intent): Screen =
LoadingScreen(carContext, repository, appPrefs)
private var monitorStarted = false

override fun onCreateScreen(intent: Intent): Screen {
if (!monitorStarted) {
monitorStarted = true
startBackgroundAlertMonitor()
}
return LoadingScreen(carContext, repository, appPrefs)
}

// Started once carContext is available and runs only while Android Auto is connected:
// lifecycleScope is cancelled automatically when the session's lifecycle is destroyed
// (car disconnected), so alert-enabled profiles are checked even when they aren't the
// one currently shown on screen, without any polling happening while the app isn't in use.
private fun startBackgroundAlertMonitor() {
val monitor = BackgroundAlertMonitor(carContext, repository)
lifecycleScope.launch {
appPrefs.refreshIntervalSeconds.collectLatest { intervalSeconds ->
while (isActive) {
monitor.checkAll()
delay(intervalSeconds * 1000L)
}
}
}
}
}
96 changes: 96 additions & 0 deletions app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package de.autosugar.car

import androidx.car.app.CarContext
import de.autosugar.data.model.GlucoseEntry
import de.autosugar.data.model.NightscoutProfile
import de.autosugar.data.repository.NightscoutRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first

/**
* Evaluates alert thresholds for every alert-enabled profile, independent of which profile is
* currently shown on the Android Auto screen. Keyed throughout by each profile's own id, so
* (unlike the screen, which only ever tracks the single active profile) a profile that isn't
* on screen still gets its alerts checked, and profiles can never cross-contaminate each other's
* data or cooldowns.
*/
class BackgroundAlertMonitor(
carContext: CarContext,
private val repository: NightscoutRepository,
) {
private val alertManager = GlucoseAlertManager(carContext)
private val alertCooldownMs = 15 * 60_000L

// A reading older than this is considered stale (≥2 missed 5-min CGM readings) and never
// triggers an alert, since acting on outdated glucose data is worse than not alerting.
private val staleAfterMs = 12 * 60_000L

private val lastHighAlertMs = mutableMapOf<String, Long>()
private val lastLowAlertMs = mutableMapOf<String, Long>()
private val lastPredictedHighAlertMs = mutableMapOf<String, Long>()
private val lastPredictedLowAlertMs = mutableMapOf<String, Long>()

// Recent history per profile, kept only to derive that profile's own reading cadence for
// the 15-minute-ahead projection.
private val historyByProfile = mutableMapOf<String, List<GlucoseEntry>>()

suspend fun checkAll() = coroutineScope {
val profiles = repository.profilesFlow.first().filter { it.alertsEnabled }
profiles.map { profile -> async { checkProfile(profile) } }.awaitAll()
}

private suspend fun checkProfile(profile: NightscoutProfile) = coroutineScope {
val entryResult = async { repository.getCurrentEntry(profile.id) }
val thresholdsResult = async { repository.getThresholds(profile.id) }
val historyResult = async { repository.getHistory(profile.id, count = 36) }

val entry = entryResult.await().getOrNull() ?: return@coroutineScope
val thresholds = thresholdsResult.await().getOrNull() ?: return@coroutineScope
historyResult.await().getOrNull()?.let { h ->
historyByProfile[profile.id] = h.sortedBy { it.dateMs }
}

val sgv = entry.sgv
val now = System.currentTimeMillis()
if (now - entry.dateMs > staleAfterMs) return@coroutineScope

val id = profile.id
if (sgv >= thresholds.bgHigh && now - (lastHighAlertMs[id] ?: 0L) > alertCooldownMs) {
alertManager.sendHighAlert(id, profile.displayName, sgv, profile.unit)
lastHighAlertMs[id] = now
}
if (sgv <= thresholds.bgLow && now - (lastLowAlertMs[id] ?: 0L) > alertCooldownMs) {
alertManager.sendLowAlert(id, profile.displayName, sgv, profile.unit)
lastLowAlertMs[id] = now
}

val delta = entry.delta ?: return@coroutineScope
// delta is the change over one reading interval; project 15 minutes ahead using
// the profile's actual sampling cadence rather than assuming a fixed 5-minute interval.
val projected15 = sgv + delta * projectionSteps(id)

if (projected15 > thresholds.bgHigh && sgv < thresholds.bgHigh &&
now - (lastPredictedHighAlertMs[id] ?: 0L) > alertCooldownMs
) {
alertManager.sendPredictedHighAlert(id, profile.displayName, projected15, profile.unit)
lastPredictedHighAlertMs[id] = now
}
if (projected15 < thresholds.bgLow && sgv > thresholds.bgLow &&
now - (lastPredictedLowAlertMs[id] ?: 0L) > alertCooldownMs
) {
alertManager.sendPredictedLowAlert(id, profile.displayName, projected15, profile.unit)
lastPredictedLowAlertMs[id] = now
}
}

private fun projectionSteps(profileId: String): Double {
val history = historyByProfile[profileId] ?: emptyList()
val gaps = history.zipWithNext { a, b -> b.dateMs - a.dateMs }
.filter { it in 60_000L..15 * 60_000L }
.sorted()
val intervalMs = if (gaps.isEmpty()) 5 * 60_000L else gaps[gaps.size / 2]
return 15 * 60_000.0 / intervalMs
}
}
9 changes: 6 additions & 3 deletions app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package de.autosugar.car
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.car.app.notification.CarNotificationManager
import androidx.core.app.NotificationCompat
import de.autosugar.R
import de.autosugar.data.model.GlucoseUnit
Expand All @@ -29,6 +30,7 @@ class GlucoseAlertManager(private val context: Context) {
}

private val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val carNm = CarNotificationManager.from(context)

init {
nm.createNotificationChannel(
Expand Down Expand Up @@ -92,18 +94,19 @@ class GlucoseAlertManager(private val context: Context) {
return "$value $label"
}

internal open fun buildNotification(title: String, text: String) =
internal open fun buildNotification(title: String, text: String): NotificationCompat.Builder =
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_profile_medical)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()

// Posted via CarNotificationManager (not the plain NotificationManager) so the alert is
// mirrored onto the Android Auto screen, not just the phone's notification tray.
internal open fun post(id: Int, title: String, text: String) {
try {
nm.notify(id, buildNotification(title, text))
carNm.notify(id, buildNotification(title, text))
} catch (_: SecurityException) {
// POST_NOTIFICATIONS not granted on Android 13+ — alerts silently suppressed
}
Expand Down
74 changes: 8 additions & 66 deletions app/src/main/java/de/autosugar/car/GlucoseScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,9 @@ class GlucoseScreen(
private val trendIconCache = mutableMapOf<String, CarIcon>()
private val numberIconCache = mutableMapOf<Pair<Int, Boolean>, CarIcon>()

private val alertManager = GlucoseAlertManager(carContext)
private val alertCooldownMs = 15 * 60_000L

// A reading older than this is considered stale (≥2 missed 5-min CGM readings):
// it is labelled as such in the UI and never triggers alerts, since acting on
// outdated glucose data is worse than not alerting at all.
// A reading older than this is considered stale (≥2 missed 5-min CGM readings) and is
// labelled as such in the UI. Alerting itself is handled by BackgroundAlertMonitor.
private val staleAfterMs = 12 * 60_000L
// Cooldown timers are keyed by profile id so switching profiles does not let one
// profile's recent alert suppress a genuine alert for another profile.
private val lastHighAlertMs = mutableMapOf<String, Long>()
private val lastLowAlertMs = mutableMapOf<String, Long>()
private val lastPredictedHighAlertMs = mutableMapOf<String, Long>()
private val lastPredictedLowAlertMs = mutableMapOf<String, Long>()

init {
lifecycleScope.launch {
Expand Down Expand Up @@ -152,63 +142,9 @@ class GlucoseScreen(
.onSuccess { t -> thresholds = t }
}
if (gen != fetchGeneration) return
checkAlerts()
invalidate()
}

private fun checkAlerts() {
val currentEntry = entry ?: return
val profile = profiles.find { it.id == activeProfileId } ?: return
if (!profile.alertsEnabled) return

val sgv = currentEntry.sgv
val now = System.currentTimeMillis()

// Never alert on stale data — the true current value is unknown.
if (now - currentEntry.dateMs > staleAfterMs) return

val id = profile.id
if (sgv >= thresholds.bgHigh && now - (lastHighAlertMs[id] ?: 0L) > alertCooldownMs) {
alertManager.sendHighAlert(id, profile.displayName, sgv, profile.unit)
lastHighAlertMs[id] = now
}
if (sgv <= thresholds.bgLow && now - (lastLowAlertMs[id] ?: 0L) > alertCooldownMs) {
alertManager.sendLowAlert(id, profile.displayName, sgv, profile.unit)
lastLowAlertMs[id] = now
}

val delta = currentEntry.delta ?: return
// delta is the change over one reading interval; project 15 minutes ahead using
// the actual sampling cadence rather than assuming a fixed 5-minute interval.
val projected15 = sgv + delta * projectionSteps()

if (projected15 > thresholds.bgHigh && sgv < thresholds.bgHigh &&
now - (lastPredictedHighAlertMs[id] ?: 0L) > alertCooldownMs
) {
alertManager.sendPredictedHighAlert(id, profile.displayName, projected15, profile.unit)
lastPredictedHighAlertMs[id] = now
}
if (projected15 < thresholds.bgLow && sgv > thresholds.bgLow &&
now - (lastPredictedLowAlertMs[id] ?: 0L) > alertCooldownMs
) {
alertManager.sendPredictedLowAlert(id, profile.displayName, projected15, profile.unit)
lastPredictedLowAlertMs[id] = now
}
}

/**
* How many reading intervals fit into a 15-minute look-ahead, derived from the median
* gap between recent history points. Falls back to a 5-minute cadence when unknown, so
* sources posting at 1- or 15-minute intervals project correctly instead of over/under.
*/
private fun projectionSteps(): Double {
val gaps = history.zipWithNext { a, b -> b.dateMs - a.dateMs }
.filter { it in 60_000L..15 * 60_000L }
.sorted()
val intervalMs = if (gaps.isEmpty()) 5 * 60_000L else gaps[gaps.size / 2]
return 15 * 60_000.0 / intervalMs
}

override fun onGetTemplate(): Template {
return if (carContext.carAppApiLevel >= 6 && profiles.size in 2..4) {
buildTabTemplate()
Expand Down Expand Up @@ -412,6 +348,12 @@ class GlucoseScreen(

private fun switchTo(profileId: String) {
if (profileId == activeProfileId) return
// Invalidate any in-flight fetch for the old profile immediately, synchronously —
// otherwise it could still be awaiting its network call when activeProfileId flips,
// and fetch()'s own generation bump (which only happens once its coroutine actually
// starts running) may not have happened yet, letting the old profile's data slip
// through the gen check and get evaluated/alerted under the new profile's name.
fetchGeneration++
activeProfileId = profileId
repository.setActiveProfile(profileId)
entry = null
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/xml/automotive_app_desc.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="template" />
<uses name="notification" />
</automotiveApp>
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-ar
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• التنبيهات الآن لكل مصدر، بذكر اسمه، ولا تُطلق خطأً على بيانات قديمة، ولا تعطل بعضها، ولا تُظهر (نادرًا) اسم مصدر خاطئ
• تظهر التنبيهات أيضًا على شاشة Android Auto، وتُفحص كل المصادر المفعّلة في الخلفية
• الوضع الداكن، ألوان الرسم حسب حدودك، وروابط Nightscout محلية/LAN
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-de-DE
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• Alarme sind jetzt pro Quelle, nennen die Quelle und lösen nicht mehr fälschlich bei veralteten Daten aus, blockieren sich nicht mehr gegenseitig und zeigen (selten) nicht mehr die falsche Quelle
• Alarme erscheinen jetzt auch auf dem Android-Auto-Bildschirm, und jede aktivierte Quelle wird im Hintergrund geprüft
• Dunkelmodus, Graphenfarben nach deinen Grenzwerten und lokale/LAN-Nightscout-URLs
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-en-US
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• Alerts are now per-source, name the source, and no longer misfire on stale data, block each other, or (rarely) show the wrong source
• Alerts also appear on the Android Auto screen now, and every enabled source is checked in the background
• Dark mode, graph colours matching your thresholds, and local/LAN Nightscout URLs
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-es-ES
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• Las alertas ahora son por fuente, indican la fuente y ya no fallan con datos antiguos, se bloquean entre sí ni (raramente) muestran la fuente equivocada
• Las alertas también aparecen ahora en la pantalla de Android Auto, y cada fuente activa se comprueba en segundo plano
• Modo oscuro, colores del gráfico según tus umbrales y URLs de Nightscout en local/LAN
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-fr-FR
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• Les alertes sont désormais par source, nomment la source et ne se déclenchent plus à tort sur des données anciennes, ne se bloquent plus entre elles et n'affichent (rarement) plus la mauvaise source
• Les alertes s'affichent aussi maintenant sur l'écran Android Auto, et chaque source activée est vérifiée en arrière-plan
• Mode sombre, couleurs du graphique selon vos seuils, et URL Nightscout en local/LAN
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-hi-IN
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• अलर्ट अब प्रति-स्रोत, नाम सहित; पुराना डेटा, आपसी रोक, गलत नाम — अब नहीं
• Android Auto स्क्रीन पर भी अलर्ट; बैकग्राउंड में हर सक्षम स्रोत जाँच
• डार्क मोड; सीमा अनुसार ग्राफ़ रंग; localhost/LAN URL सेव
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-it-IT
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• Gli avvisi sono ora per sorgente, ne indicano il nome e non scattano più per errore su dati obsoleti, non si bloccano più a vicenda e (raramente) non mostrano più la sorgente sbagliata
• Gli avvisi ora appaiono anche sullo schermo di Android Auto, e ogni sorgente attiva viene controllata in background
• Modalità scura, colori del grafico secondo le tue soglie e URL Nightscout locali/LAN
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-ja-JP
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• アラートはソースごとになり、ソース名を表示し、古いデータで誤作動せず、互いに妨げず、(まれに)別のソース名を表示しなくなりました
• アラートはAndroid Autoの画面にも表示され、有効な各ソースがバックグラウンドで確認されます
• ダークモード、しきい値に応じたグラフの色、localhost/LANのNightscout URLを追加
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-nl-NL
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• Meldingen zijn nu per bron, noemen de bron en gaan niet meer per ongeluk af bij oude data, blokkeren elkaar niet meer en tonen (zelden) niet meer de verkeerde bron
• Meldingen verschijnen nu ook op het Android Auto-scherm, en elke actieve bron wordt op de achtergrond gecontroleerd
• Donkere modus, grafiekkleuren volgens je drempels, en lokale/LAN Nightscout-URL's
3 changes: 3 additions & 0 deletions docs/whatsnew/1.2.5-pt-PT
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
• Os alertas são agora por fonte, indicam a fonte e já não disparam por engano com dados antigos, não se bloqueiam entre si e (raramente) não mostram a fonte errada
• Os alertas aparecem agora também no ecrã do Android Auto, e cada fonte ativa é verificada em segundo plano
• Modo escuro, cores do gráfico segundo os seus limites, e URLs Nightscout locais/LAN
Loading
Loading