diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 32baed1..bfaa158 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -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`** diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d729da..85fe028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ceb8232..b447b6b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } diff --git a/app/src/main/java/de/autosugar/car/AutoSugarSession.kt b/app/src/main/java/de/autosugar/car/AutoSugarSession.kt index b9484bb..c9b3f19 100644 --- a/app/src/main/java/de/autosugar/car/AutoSugarSession.kt +++ b/app/src/main/java/de/autosugar/car/AutoSugarSession.kt @@ -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) + } + } + } + } } diff --git a/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt b/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt new file mode 100644 index 0000000..f05fb96 --- /dev/null +++ b/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt @@ -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() + private val lastLowAlertMs = mutableMapOf() + private val lastPredictedHighAlertMs = mutableMapOf() + private val lastPredictedLowAlertMs = mutableMapOf() + + // Recent history per profile, kept only to derive that profile's own reading cadence for + // the 15-minute-ahead projection. + private val historyByProfile = mutableMapOf>() + + 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 + } +} diff --git a/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt b/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt index 51bcfa5..544b2a9 100644 --- a/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt +++ b/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt @@ -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 @@ -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( @@ -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 } diff --git a/app/src/main/java/de/autosugar/car/GlucoseScreen.kt b/app/src/main/java/de/autosugar/car/GlucoseScreen.kt index 6c6b00d..416de58 100644 --- a/app/src/main/java/de/autosugar/car/GlucoseScreen.kt +++ b/app/src/main/java/de/autosugar/car/GlucoseScreen.kt @@ -74,19 +74,9 @@ class GlucoseScreen( private val trendIconCache = mutableMapOf() private val numberIconCache = mutableMapOf, 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() - private val lastLowAlertMs = mutableMapOf() - private val lastPredictedHighAlertMs = mutableMapOf() - private val lastPredictedLowAlertMs = mutableMapOf() init { lifecycleScope.launch { @@ -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() @@ -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 diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml index 0fb852c..8b46ed0 100644 --- a/app/src/main/res/xml/automotive_app_desc.xml +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -1,4 +1,5 @@ + diff --git a/docs/whatsnew/1.2.5-ar b/docs/whatsnew/1.2.5-ar new file mode 100644 index 0000000..f7ec0e7 --- /dev/null +++ b/docs/whatsnew/1.2.5-ar @@ -0,0 +1,3 @@ +• التنبيهات الآن لكل مصدر، بذكر اسمه، ولا تُطلق خطأً على بيانات قديمة، ولا تعطل بعضها، ولا تُظهر (نادرًا) اسم مصدر خاطئ +• تظهر التنبيهات أيضًا على شاشة Android Auto، وتُفحص كل المصادر المفعّلة في الخلفية +• الوضع الداكن، ألوان الرسم حسب حدودك، وروابط Nightscout محلية/LAN diff --git a/docs/whatsnew/1.2.5-de-DE b/docs/whatsnew/1.2.5-de-DE new file mode 100644 index 0000000..226d55a --- /dev/null +++ b/docs/whatsnew/1.2.5-de-DE @@ -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 diff --git a/docs/whatsnew/1.2.5-en-US b/docs/whatsnew/1.2.5-en-US new file mode 100644 index 0000000..a9e859d --- /dev/null +++ b/docs/whatsnew/1.2.5-en-US @@ -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 diff --git a/docs/whatsnew/1.2.5-es-ES b/docs/whatsnew/1.2.5-es-ES new file mode 100644 index 0000000..ccb9051 --- /dev/null +++ b/docs/whatsnew/1.2.5-es-ES @@ -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 diff --git a/docs/whatsnew/1.2.5-fr-FR b/docs/whatsnew/1.2.5-fr-FR new file mode 100644 index 0000000..4b0d850 --- /dev/null +++ b/docs/whatsnew/1.2.5-fr-FR @@ -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 diff --git a/docs/whatsnew/1.2.5-hi-IN b/docs/whatsnew/1.2.5-hi-IN new file mode 100644 index 0000000..828121a --- /dev/null +++ b/docs/whatsnew/1.2.5-hi-IN @@ -0,0 +1,3 @@ +• अलर्ट अब प्रति-स्रोत, नाम सहित; पुराना डेटा, आपसी रोक, गलत नाम — अब नहीं +• Android Auto स्क्रीन पर भी अलर्ट; बैकग्राउंड में हर सक्षम स्रोत जाँच +• डार्क मोड; सीमा अनुसार ग्राफ़ रंग; localhost/LAN URL सेव diff --git a/docs/whatsnew/1.2.5-it-IT b/docs/whatsnew/1.2.5-it-IT new file mode 100644 index 0000000..7cde1f7 --- /dev/null +++ b/docs/whatsnew/1.2.5-it-IT @@ -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 diff --git a/docs/whatsnew/1.2.5-ja-JP b/docs/whatsnew/1.2.5-ja-JP new file mode 100644 index 0000000..3a3db33 --- /dev/null +++ b/docs/whatsnew/1.2.5-ja-JP @@ -0,0 +1,3 @@ +• アラートはソースごとになり、ソース名を表示し、古いデータで誤作動せず、互いに妨げず、(まれに)別のソース名を表示しなくなりました +• アラートはAndroid Autoの画面にも表示され、有効な各ソースがバックグラウンドで確認されます +• ダークモード、しきい値に応じたグラフの色、localhost/LANのNightscout URLを追加 diff --git a/docs/whatsnew/1.2.5-nl-NL b/docs/whatsnew/1.2.5-nl-NL new file mode 100644 index 0000000..d90eccc --- /dev/null +++ b/docs/whatsnew/1.2.5-nl-NL @@ -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 diff --git a/docs/whatsnew/1.2.5-pt-PT b/docs/whatsnew/1.2.5-pt-PT new file mode 100644 index 0000000..50a3192 --- /dev/null +++ b/docs/whatsnew/1.2.5-pt-PT @@ -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 diff --git a/docs/whatsnew/1.2.5-zh-CN b/docs/whatsnew/1.2.5-zh-CN new file mode 100644 index 0000000..9f95307 --- /dev/null +++ b/docs/whatsnew/1.2.5-zh-CN @@ -0,0 +1,3 @@ +• 提醒现在按数据源区分、显示来源名称,不再因旧数据误报、互相抑制,也(极少数情况下)不再显示错误来源 +• 提醒现在也会显示在 Android Auto 屏幕上,且每个已启用的数据源都会在后台被检查 +• 新增深色模式、按你的阈值着色的图表,以及本地/局域网 Nightscout 网址