From f597cb03605d1518ba4905799164156ca8c5f2c1 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Sun, 6 Sep 2026 14:47:02 +0200 Subject: [PATCH 1/5] ci(google-play): stop setting changesNotSentForReview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Play rejects the parameter for any edit it has decided must be reviewed: "Changes are sent for review automatically. The query parameter changesNotSentForReview must not be set." The 1.2.7 upload hit exactly that at the commit step, after the AAB had already uploaded — nothing in the workflow had changed since 1.2.6, which committed successfully with the same flag. What changed is the release: 1.2.7 adds FOREGROUND_SERVICE_DATA_SYNC and a dataSync foreground service, and a new foreground service type requires a Play Console declaration and is subject to review, so Play forces the edit through review and refuses to let the flag opt out of it. Holding changes back from review is the wrong default for this app anyway. Reviews here are slow, which is why AGENTS.md tells us to stay ahead of Play's deadlines; a release parked in the console waiting for someone to press "send for review" only adds to the latency it warns about. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/google-play.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/google-play.yml b/.github/workflows/google-play.yml index ffeba05..94ad6f0 100644 --- a/.github/workflows/google-play.yml +++ b/.github/workflows/google-play.yml @@ -83,4 +83,13 @@ jobs: tracks: ${{ inputs.track || 'internal' }} whatsNewDirectory: whatsnew/ mappingFile: release-assets/mapping.txt - changesNotSentForReview: true + # `changesNotSentForReview` is deliberately not set. Play rejects the parameter + # outright ("Changes are sent for review automatically. The query parameter + # changesNotSentForReview must not be set.") for any edit it has decided must be + # reviewed — which includes every release that adds a permission needing a Play + # Console declaration, such as a new foreground service type. It broke the 1.2.7 + # upload after working for 1.2.6, since nothing in the workflow had changed. + # + # Holding changes back from review is also the wrong default here: reviews for this + # app are slow, so a release that sits in the console waiting for someone to press + # "send for review" only adds latency (see AGENTS.md, Maintenance Policy). From 7a625d4e2f82bf5fbe693769f3be6e21c524a3c8 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Sun, 6 Sep 2026 17:34:34 +0200 Subject: [PATCH 2/5] feat(car)!: stop forecasting glucose and only notify on Nightscout data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monitor derived its own 15-minute forecast — projected15 = sgv + delta * projectionSteps — and raised "trending high/low" notifications from it. Nothing in the Nightscout status payload carries a prediction; that number was entirely AutoSugar's, which made the app generate new clinical information rather than display the user's own. Prediction and prognosis are named explicitly in the EU MDR's software rule, so it was also the single most medical-device-like thing in an app that is meant to be a viewer for Nightscout. Drop both forecast notification types. The trend arrow and delta already shown on the car screen come from Nightscout itself, so the "where is this heading" information survives without the app forecasting on the user's behalf. Threshold notifications are untouched. Notification ids 1003/1004 are left unused rather than reassigned, so a forecast notification still showing on an older install can never be replaced by an unrelated one. Dropping the forecast also removes the per-check history fetch it needed, so each poll now makes two requests instead of three. Co-Authored-By: Claude Opus 5 (1M context) --- .../autosugar/car/BackgroundAlertMonitor.kt | 55 ++++--------------- .../de/autosugar/car/GlucoseAlertManager.kt | 21 +------ app/src/main/res/values-ar/strings.xml | 3 - app/src/main/res/values-de/strings.xml | 3 - app/src/main/res/values-es/strings.xml | 3 - app/src/main/res/values-fr/strings.xml | 3 - app/src/main/res/values-hi/strings.xml | 3 - app/src/main/res/values-it/strings.xml | 3 - app/src/main/res/values-ja/strings.xml | 3 - app/src/main/res/values-nl/strings.xml | 3 - app/src/main/res/values-pt/strings.xml | 3 - app/src/main/res/values-zh/strings.xml | 3 - app/src/main/res/values/strings.xml | 3 - .../autosugar/car/GlucoseAlertManagerTest.kt | 44 +-------------- 14 files changed, 15 insertions(+), 138 deletions(-) diff --git a/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt b/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt index 0ef2644..a43fccc 100644 --- a/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt +++ b/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt @@ -1,7 +1,6 @@ package de.autosugar.car import android.content.Context -import de.autosugar.data.model.GlucoseEntry import de.autosugar.data.model.NightscoutProfile import de.autosugar.data.repository.NightscoutRepository import kotlinx.coroutines.async @@ -10,11 +9,16 @@ 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. + * Checks every alert-enabled source against the thresholds configured on that source's own + * Nightscout server, independent of which source 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 checked, and profiles can never + * cross-contaminate each other's data or cooldowns. + * + * Deliberately performs no interpretation of its own. It compares the reading Nightscout reported + * against the bounds Nightscout reported and notifies when one is crossed — nothing here decides + * what counts as high or low, and nothing here forecasts where a reading is heading. AutoSugar is + * a display for the user's own Nightscout data, so every number it acts on has to come from there. */ class BackgroundAlertMonitor( context: Context, @@ -24,17 +28,11 @@ class BackgroundAlertMonitor( 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. + // triggers a notification, since surfacing outdated glucose data is worse than staying quiet. 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.enabledProfilesFlow.first().filter { it.alertsEnabled } @@ -44,13 +42,9 @@ class BackgroundAlertMonitor( 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() @@ -65,32 +59,5 @@ class BackgroundAlertMonitor( 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 ac7ecf7..4ac1599 100644 --- a/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt +++ b/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt @@ -18,8 +18,9 @@ class GlucoseAlertManager(private val context: Context) { private const val CHANNEL_ID = "glucose_alerts" private const val NOTIF_HIGH = 1001 private const val NOTIF_LOW = 1002 - private const val NOTIF_PREDICTED_HIGH = 1003 - private const val NOTIF_PREDICTED_LOW = 1004 + // 1003/1004 belonged to the removed "trending high/low" forecasts. Left unused rather + // than reassigned, so a notification still showing on an older install is never replaced + // by an unrelated one. private const val NOTIF_MONITORING_STOPPED = 1005 /** @@ -81,22 +82,6 @@ class GlucoseAlertManager(private val context: Context) { ) } - fun sendPredictedHighAlert(profileId: String, profileName: String, projectedSgv: Double, unit: GlucoseUnit) { - post( - id = notifId(NOTIF_PREDICTED_HIGH, profileId), - title = titled(profileName, R.string.notif_title_predicted_high), - text = context.getString(R.string.notif_text_predicted, formatValue(projectedSgv, unit)), - ) - } - - fun sendPredictedLowAlert(profileId: String, profileName: String, projectedSgv: Double, unit: GlucoseUnit) { - post( - id = notifId(NOTIF_PREDICTED_LOW, profileId), - title = titled(profileName, R.string.notif_title_predicted_low), - text = context.getString(R.string.notif_text_predicted, formatValue(projectedSgv, unit)), - ) - } - /** * Tells the driver that alerting itself has stopped — currently only when Android caps the * monitor's foreground-service budget. Not tied to a profile: monitoring stops for all of them diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5c7115a..2a8b75d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -44,9 +44,6 @@ تنبيهات ارتفاع وانخفاض سكر الدم مع فترة راحة تكرار 15 دقيقة ارتفاع السكر انخفاض السكر - السكر في ارتفاع - السكر في انخفاض - متوقع خلال 15 دقيقة: %s المراقبة diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b634f50..5f626fd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -44,9 +44,6 @@ Alarme bei hohem und niedrigem Blutzucker mit 15-Minuten-Wiederholungsintervall Hoher Blutzucker Niedriger Blutzucker - Blutzucker steigt - Blutzucker fällt - Voraussichtlich in 15 Min.: %s Überwachung diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2fb0603..e13b209 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -44,9 +44,6 @@ Alertas de glucosa alta y baja con intervalo de repetición de 15 minutos Glucosa alta Glucosa baja - Glucosa en tendencia alta - Glucosa en tendencia baja - Predicción en 15 min: %s Monitorización diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6484073..6e3076a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -44,9 +44,6 @@ Alertes d\'hyperglycémie et d\'hypoglycémie avec intervalle de répétition de 15 minutes Glycémie élevée Glycémie basse - Glycémie en hausse - Glycémie en baisse - Prévision dans 15 min : %s Surveillance diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 0951dbc..ac6b7eb 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -44,9 +44,6 @@ 15 मिनट की पुनरावृत्ति के साथ उच्च और निम्न रक्त शर्करा अलर्ट उच्च रक्त शर्करा निम्न रक्त शर्करा - ग्लूकोज़ बढ़ रहा है - ग्लूकोज़ घट रहा है - 15 मिनट में अनुमानित: %s निगरानी diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4f8b3a8..cea247a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -44,9 +44,6 @@ Avvisi per glicemia alta e bassa con intervallo di ripetizione di 15 minuti Glicemia alta Glicemia bassa - Glicemia in aumento - Glicemia in calo - Previsto tra 15 min: %s Monitoraggio diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 62536f0..44211ff 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -44,9 +44,6 @@ 15分間隔の繰り返しで高血糖・低血糖アラート 高血糖 低血糖 - 血糖値上昇傾向 - 血糖値下降傾向 - 15分後の予測: %s 監視 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1322ac7..c8e41e4 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -44,9 +44,6 @@ Meldingen voor hoge en lage bloedglucose met herhalings-cooldown van 15 minuten Hoge glucose Lage glucose - Glucose stijgt - Glucose daalt - Voorspelling over 15 min: %s Bewaking diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 52cc0de..f42f9dd 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -44,9 +44,6 @@ Alertas de glicose alta e baixa com intervalo de repetição de 15 minutos Glicose alta Glicose baixa - Glicose tendendo a subir - Glicose tendendo a cair - Previsto em 15 min: %s Monitorização diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index c150fe9..2a15fec 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -44,9 +44,6 @@ 高低血糖提醒,15 分钟重复间隔 血糖偏高 血糖偏低 - 血糖呈上升趋势 - 血糖呈下降趋势 - 预计 15 分钟后:%s 监测 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0317c5a..b328ef5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -45,9 +45,6 @@ High and low blood glucose alerts with 15-minute repeat cooldown High glucose Low glucose - Glucose trending high - Glucose trending low - Predicted in 15 min: %s Monitoring diff --git a/app/src/test/java/de/autosugar/car/GlucoseAlertManagerTest.kt b/app/src/test/java/de/autosugar/car/GlucoseAlertManagerTest.kt index 6cbd50e..5144132 100644 --- a/app/src/test/java/de/autosugar/car/GlucoseAlertManagerTest.kt +++ b/app/src/test/java/de/autosugar/car/GlucoseAlertManagerTest.kt @@ -40,15 +40,8 @@ class GlucoseAlertManagerTest { every { mockContext.getString(R.string.label_unit_mmoll) } returns "mmol/L" every { mockContext.getString(R.string.notif_title_high) } returns "High glucose" every { mockContext.getString(R.string.notif_title_low) } returns "Low glucose" - every { mockContext.getString(R.string.notif_title_predicted_high) } returns "Glucose trending high" - every { mockContext.getString(R.string.notif_title_predicted_low) } returns "Glucose trending low" every { mockContext.getString(R.string.notif_title_monitoring_stopped) } returns "Glucose monitoring stopped" every { mockContext.getString(R.string.notif_text_monitoring_stopped) } returns "Open AutoSugar on the car screen to resume alerts" - every { mockContext.getString(R.string.notif_text_predicted, any()) } answers { - @Suppress("UNCHECKED_CAST") - val formatArgs = it.invocation.args[1] as Array - "Predicted in 15 min: ${formatArgs[0]}" - } every { mockContext.packageName } returns "de.autosugar" every { mockContext.applicationInfo } returns mockk(relaxed = true) justRun { mockNm.createNotificationChannel(any()) } @@ -109,19 +102,6 @@ class GlucoseAlertManagerTest { assertTrue("Expected distinct notification IDs", idSlots[0] != idSlots[1]) } - @Test - fun `all four alert types use distinct notification IDs`() { - val manager = buildManager() - val idSlots = mutableListOf() - justRun { manager.post(capture(idSlots), any(), any()) } - - manager.sendHighAlert(PROFILE_ID, PROFILE_NAME, 200.0, GlucoseUnit.MG_DL) - manager.sendLowAlert(PROFILE_ID, PROFILE_NAME, 55.0, GlucoseUnit.MG_DL) - manager.sendPredictedHighAlert(PROFILE_ID, PROFILE_NAME, 195.0, GlucoseUnit.MG_DL) - manager.sendPredictedLowAlert(PROFILE_ID, PROFILE_NAME, 65.0, GlucoseUnit.MG_DL) - - assertEquals(4, idSlots.distinct().size) - } @Test fun `same alert type for different profiles uses distinct notification IDs`() { @@ -137,26 +117,6 @@ class GlucoseAlertManagerTest { // endregion - // region predicted alerts - - @Test - fun `sendPredictedHighAlert includes predicted value in notification text`() { - val manager = buildManager() - manager.sendPredictedHighAlert(PROFILE_ID, PROFILE_NAME, projectedSgv = 200.0, unit = GlucoseUnit.MG_DL) - - verify { manager.post(any(), any(), match { it.contains("200 mg/dL") }) } - } - - @Test - fun `sendPredictedLowAlert includes predicted value in notification text`() { - val manager = buildManager() - manager.sendPredictedLowAlert(PROFILE_ID, PROFILE_NAME, projectedSgv = 60.0, unit = GlucoseUnit.MG_DL) - - verify { manager.post(any(), any(), match { it.contains("60 mg/dL") }) } - } - - // endregion - // region monitoring stopped @Test @@ -167,11 +127,9 @@ class GlucoseAlertManagerTest { manager.sendHighAlert(PROFILE_ID, PROFILE_NAME, 200.0, GlucoseUnit.MG_DL) manager.sendLowAlert(PROFILE_ID, PROFILE_NAME, 55.0, GlucoseUnit.MG_DL) - manager.sendPredictedHighAlert(PROFILE_ID, PROFILE_NAME, 195.0, GlucoseUnit.MG_DL) - manager.sendPredictedLowAlert(PROFILE_ID, PROFILE_NAME, 65.0, GlucoseUnit.MG_DL) manager.sendMonitoringStoppedAlert() - assertEquals(5, idSlots.distinct().size) + assertEquals(3, idSlots.distinct().size) } @Test From 6ba85b8fb2bcad0ebf43308b9cd5ee26146051b9 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Sun, 6 Sep 2026 17:43:26 +0200 Subject: [PATCH 3/5] feat(settings): notify only on thresholds Nightscout reports, and explain it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getThresholds substituted 70/180 when a user's Nightscout reported no thresholds, and the monitor then notified against them. Those numbers were AutoSugar's own choice of what counts as high or low for someone — the one judgement a display for Nightscout data must not make on a user's behalf. Split the two uses of the values apart. bgLow/bgHigh stay populated for the graph, which needs a scale and a band regardless, but alertLow/alertHigh carry only what Nightscout actually reported and are the only bounds the monitor will act on, per bound rather than all-or-nothing. A source whose Nightscout defines none now stays quiet instead of being measured against a default, and testing the connection says so on the phone rather than leaving the user to infer it from silence. Enabling a source's notifications now opens an explainer first: where the thresholds come from, that the graph only joins real readings with straight lines and forecasts nothing, that stale readings are skipped and repeats are limited, and that AutoSugar is not a medical device. The permission request follows the explanation, which is also the order Android recommends for rationale. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++ .../autosugar/car/BackgroundAlertMonitor.kt | 10 ++- .../autosugar/data/model/GlucoseThresholds.kt | 19 ++++- .../data/repository/NightscoutRepository.kt | 38 +++++++-- .../ui/settings/ProfileEditScreen.kt | 47 ++++++++++- .../ui/settings/ProfileEditViewModel.kt | 14 ++++ app/src/main/res/values-ar/strings.xml | 6 ++ app/src/main/res/values-de/strings.xml | 6 ++ app/src/main/res/values-es/strings.xml | 6 ++ app/src/main/res/values-fr/strings.xml | 6 ++ app/src/main/res/values-hi/strings.xml | 6 ++ app/src/main/res/values-it/strings.xml | 6 ++ app/src/main/res/values-ja/strings.xml | 6 ++ app/src/main/res/values-nl/strings.xml | 6 ++ app/src/main/res/values-pt/strings.xml | 6 ++ app/src/main/res/values-zh/strings.xml | 6 ++ app/src/main/res/values/strings.xml | 6 ++ .../repository/NightscoutRepositoryTest.kt | 78 ++++++++++++++++++- .../ui/settings/ProfileEditViewModelTest.kt | 61 +++++++++++++++ 19 files changed, 323 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4043b2..7339c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- AutoSugar no longer forecasts glucose. The "trending high/low" notifications projected a reading 15 minutes ahead from a number the app derived itself; nothing in the Nightscout payload carries a prediction. The trend arrow and delta on the car screen come from Nightscout and are unchanged +- Notifications now fire only on thresholds the source's own Nightscout reports. Where it reports none, AutoSugar used to substitute 70/180 — values it chose — and notify on those; it now stays quiet and says so on the phone. The graph still falls back to a display range so it always has a scale to draw +- Switching a source's notifications on now explains how they work first: thresholds come from your own Nightscout, the graph joins real readings with straight lines and predicts nothing, stale readings are skipped, repeats are limited — and AutoSugar is a display for Nightscout data, not a medical device. The notification permission is requested after that explanation, which is also the order Android recommends + ## [1.2.7] - 2026-09-06 ### Added diff --git a/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt b/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt index a43fccc..293aadf 100644 --- a/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt +++ b/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt @@ -50,12 +50,18 @@ class BackgroundAlertMonitor( val now = System.currentTimeMillis() if (now - entry.dateMs > staleAfterMs) return@coroutineScope + // Only the bounds this user's own Nightscout reported. A source whose Nightscout defines + // no thresholds is never notified on, rather than being measured against a default + // AutoSugar picked; the phone's edit screen tells the user when that is the case. val id = profile.id - if (sgv >= thresholds.bgHigh && now - (lastHighAlertMs[id] ?: 0L) > alertCooldownMs) { + val high = thresholds.alertHigh + val low = thresholds.alertLow + + if (high != null && sgv >= high && 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) { + if (low != null && sgv <= low && now - (lastLowAlertMs[id] ?: 0L) > alertCooldownMs) { alertManager.sendLowAlert(id, profile.displayName, sgv, profile.unit) lastLowAlertMs[id] = now } diff --git a/app/src/main/java/de/autosugar/data/model/GlucoseThresholds.kt b/app/src/main/java/de/autosugar/data/model/GlucoseThresholds.kt index fd02e98..a7f22eb 100644 --- a/app/src/main/java/de/autosugar/data/model/GlucoseThresholds.kt +++ b/app/src/main/java/de/autosugar/data/model/GlucoseThresholds.kt @@ -1,14 +1,25 @@ package de.autosugar.data.model /** - * All four Nightscout threshold values, in mg/dL. + * Nightscout's threshold values, in mg/dL. * - * bgLow / bgHigh are the alert boundaries (outside these = alert). - * bgTargetBottom / bgTargetTop are the target-range boundaries (used for the green graph band). + * [bgLow]/[bgHigh] and [bgTargetBottom]/[bgTargetTop] are always populated, because the graph + * needs a scale and a target band to draw; where Nightscout reported nothing they hold + * AutoSugar's display defaults. + * + * [alertLow]/[alertHigh] are the same low/high bounds *only* where the user's own Nightscout + * actually reported them, and null otherwise. Notifications must be driven from these, never from + * the display values: falling back there would mean AutoSugar deciding what counts as high or low + * for someone, which is a judgement a display for Nightscout data must not make on their behalf. */ data class GlucoseThresholds( val bgLow: Int, val bgTargetBottom: Int, val bgTargetTop: Int, val bgHigh: Int, -) + val alertLow: Int? = null, + val alertHigh: Int? = null, +) { + /** True when Nightscout reported at least one bound, so a notification is possible at all. */ + val canNotify: Boolean get() = alertLow != null || alertHigh != null +} diff --git a/app/src/main/java/de/autosugar/data/repository/NightscoutRepository.kt b/app/src/main/java/de/autosugar/data/repository/NightscoutRepository.kt index dc2a33f..98bebe7 100644 --- a/app/src/main/java/de/autosugar/data/repository/NightscoutRepository.kt +++ b/app/src/main/java/de/autosugar/data/repository/NightscoutRepository.kt @@ -80,18 +80,36 @@ class NightscoutRepository @Inject constructor( private fun isoDate(dateString: String?, dateMs: Long): String = dateString ?: java.time.Instant.ofEpochMilli(dateMs).toString() - /** Returns all four Nightscout threshold values in mg/dL. */ + /** Reads the thresholds configured on a saved profile's Nightscout instance. */ suspend fun getThresholds(profileId: String): Result = runCatching { val profiles = dataStore.profilesFlow.first() val profile = profiles.find { it.id == profileId } ?: error("Profile $profileId not found") + fetchThresholds(profile) + } + + /** + * Same as [getThresholds] for a profile that may not be saved yet, so the edit screen can + * tell the user whether their Nightscout defines any thresholds before they rely on them. + */ + suspend fun getThresholdsFor(profile: NightscoutProfile): Result = + runCatching { fetchThresholds(profile) } + + private suspend fun fetchThresholds(profile: NightscoutProfile): GlucoseThresholds { val api = apiFactory.get(profile.baseUrl) val t = api.getStatus(token = profile.apiToken.ifBlank { null }).settings?.thresholds - GlucoseThresholds( - bgLow = t?.bgLow?.roundToInt() ?: 70, - bgTargetBottom = t?.bgTargetBottom?.roundToInt() ?: 70, - bgTargetTop = t?.bgTargetTop?.roundToInt() ?: 180, - bgHigh = t?.bgHigh?.roundToInt() ?: 180, + val low = t?.bgLow?.roundToInt() + val high = t?.bgHigh?.roundToInt() + return GlucoseThresholds( + // Display-only fallbacks: the graph still needs a scale and a band when Nightscout + // defines none. They are deliberately not passed to alertLow/alertHigh below, so a + // number AutoSugar picked can never be the reason someone gets notified. + bgLow = low ?: DISPLAY_LOW, + bgTargetBottom = t?.bgTargetBottom?.roundToInt() ?: DISPLAY_TARGET_BOTTOM, + bgTargetTop = t?.bgTargetTop?.roundToInt() ?: DISPLAY_TARGET_TOP, + bgHigh = high ?: DISPLAY_HIGH, + alertLow = low, + alertHigh = high, ) } @@ -170,4 +188,12 @@ class NightscoutRepository @Inject constructor( suspend fun saveAll(profiles: List) { dataStore.save(profiles) } + + private companion object { + // Used purely to render a graph when Nightscout defines no thresholds. Never notified on. + const val DISPLAY_LOW = 70 + const val DISPLAY_TARGET_BOTTOM = 70 + const val DISPLAY_TARGET_TOP = 180 + const val DISPLAY_HIGH = 180 + } } diff --git a/app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt b/app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt index b1baa3d..091ebdf 100644 --- a/app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt +++ b/app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip @@ -34,6 +35,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -84,10 +86,12 @@ fun ProfileEditScreen( val icon by viewModel.icon.collectAsState() val alertsEnabled by viewModel.alertsEnabled.collectAsState() val tokenOverpowered by viewModel.tokenOverpowered.collectAsState() + val thresholdsMissing by viewModel.thresholdsMissing.collectAsState() val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current var notificationsDenied by remember { mutableStateOf(false) } + var showNotificationInfo by remember { mutableStateOf(false) } val notificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() @@ -140,6 +144,27 @@ fun ProfileEditScreen( } } + if (showNotificationInfo) { + // Dismissing counts as acknowledged: the toggle is already on, so the permission request + // has to follow either way rather than leaving alerts enabled and never requested. + val acknowledge = { + showNotificationInfo = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + AlertDialog( + onDismissRequest = acknowledge, + title = { Text(stringResource(R.string.dialog_notifications_title)) }, + text = { Text(stringResource(R.string.dialog_notifications_body)) }, + confirmButton = { + TextButton(onClick = acknowledge) { + Text(stringResource(R.string.btn_got_it)) + } + }, + ) + } + val isLoading = uiState is ProfileEditUiState.Loading val urlScheme = runCatching { java.net.URI(baseUrl.trim()).scheme }.getOrNull() val isValidUrl = runCatching { @@ -273,13 +298,29 @@ fun ProfileEditScreen( checked = alertsEnabled, onCheckedChange = { enabled -> viewModel.alertsEnabled.value = enabled - if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } + // Explain what the notifications are before asking for the permission: + // Android recommends showing the rationale first, and it is the one moment + // the user is definitely thinking about this feature. + if (enabled) showNotificationInfo = true }, enabled = !isLoading, ) } + if (thresholdsMissing) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.warning_no_thresholds), + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } // ── Tab icon ───────────────────────────────────────────────── Text(stringResource(R.string.label_tab_icon)) diff --git a/app/src/main/java/de/autosugar/ui/settings/ProfileEditViewModel.kt b/app/src/main/java/de/autosugar/ui/settings/ProfileEditViewModel.kt index 8ba568c..727f063 100644 --- a/app/src/main/java/de/autosugar/ui/settings/ProfileEditViewModel.kt +++ b/app/src/main/java/de/autosugar/ui/settings/ProfileEditViewModel.kt @@ -48,6 +48,14 @@ class ProfileEditViewModel @Inject constructor( private val _tokenOverpowered = MutableStateFlow(false) val tokenOverpowered: StateFlow = _tokenOverpowered.asStateFlow() + /** + * True when the tested Nightscout instance reports no glucose thresholds. AutoSugar notifies + * only on bounds that instance defines, so in that case enabling alerts here can never produce + * anything — which the user has to be told rather than left to discover by silence. + */ + private val _thresholdsMissing = MutableStateFlow(false) + val thresholdsMissing: StateFlow = _thresholdsMissing.asStateFlow() + fun loadProfile(profileId: String) { viewModelScope.launch { val profile = repository.profilesFlow.first().find { it.id == profileId } ?: return@launch @@ -65,6 +73,7 @@ class ProfileEditViewModel @Inject constructor( fun testConnection() { _uiState.value = ProfileEditUiState.Loading _tokenOverpowered.value = false + _thresholdsMissing.value = false val tempProfile = buildProfile() viewModelScope.launch { repository.testConnection(tempProfile) @@ -72,6 +81,11 @@ class ProfileEditViewModel @Inject constructor( _tokenOverpowered.value = runCatching { repository.hasElevatedPermissions(tempProfile) }.getOrDefault(false) + // Only treat it as missing on a successful read; a failed status call says + // nothing about whether thresholds are configured. + _thresholdsMissing.value = repository.getThresholdsFor(tempProfile) + .map { !it.canNotify } + .getOrDefault(false) _uiState.value = ProfileEditUiState.TestSuccess( value = entry.displayValue(tempProfile.unit), unit = tempProfile.unit, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 2a8b75d..f5f684e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -40,6 +40,12 @@ تنبيهات الجلوكوز التنبيهات مفعّلة + + + كيف تعمل الإشعارات + ينبّهك AutoSugar عندما يتجاوز القياس حدّاً ضبطته في خادم Nightscout الخاص بك. ولا يقوم AutoSugar بتعيين هذه الحدود مطلقاً — فإن لم يحددها خادمك، لن يُرسل شيء.\n\nيربط الرسم البياني قياساتك الفعلية بخطوط مستقيمة، دون أي تمهيد أو توقّع، ولا يتنبأ AutoSugar بمسار الجلوكوز لديك.\n\nتُهمل القياسات الأقدم من 12 دقيقة، ويتكرر كل إشعار كل 15 دقيقة على الأكثر.\n\nيعرض AutoSugar بيانات Nightscout الخاصة بك وليس جهازاً طبياً. لا تستخدمه لقرارات العلاج. + فهمت + لا يحدد خادم Nightscout هذا أي حدود للجلوكوز، لذلك لا يمكن إرسال إشعارات لهذا المصدر. اضبط الحدود في إعدادات Nightscout — فلا يستخدم AutoSugar سواها. تنبيهات الجلوكوز تنبيهات ارتفاع وانخفاض سكر الدم مع فترة راحة تكرار 15 دقيقة ارتفاع السكر diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5f626fd..be18f6c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -40,6 +40,12 @@ Glukose-Alarme Alarme aktiv + + + So funktionieren Benachrichtigungen + AutoSugar benachrichtigt dich, wenn ein Messwert einen Grenzwert überschreitet, den du in deiner eigenen Nightscout-Instanz konfiguriert hast. AutoSugar legt diese Grenzwerte nie selbst fest — definiert dein Nightscout keine, wird nichts gesendet.\n\nDer Graph verbindet deine tatsächlichen Messwerte mit geraden Linien. Es wird nichts geglättet oder vorhergesagt, und AutoSugar prognostiziert keine künftigen Werte.\n\nMesswerte, die älter als 12 Minuten sind, werden ignoriert; jede Benachrichtigung wiederholt sich höchstens alle 15 Minuten.\n\nAutoSugar zeigt deine Nightscout-Daten an und ist kein Medizinprodukt. Nutze es nicht für Therapieentscheidungen. + Verstanden + Diese Nightscout-Instanz definiert keine Glukose-Grenzwerte, daher können für diese Quelle keine Benachrichtigungen gesendet werden. Lege die Grenzwerte in deiner Nightscout-Konfiguration fest — AutoSugar verwendet ausschließlich die von dort gelesenen Werte. Glukose-Alarme Alarme bei hohem und niedrigem Blutzucker mit 15-Minuten-Wiederholungsintervall Hoher Blutzucker diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e13b209..40ee377 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -40,6 +40,12 @@ Alertas de glucosa Alertas activadas + + + Cómo funcionan las notificaciones + AutoSugar te avisa cuando una lectura cruza un umbral que has configurado en tu propia instancia de Nightscout. AutoSugar nunca define esos umbrales — si tu Nightscout no define ninguno, no se envía nada.\n\nLa gráfica une tus lecturas reales con líneas rectas. Nada se suaviza ni se predice, y AutoSugar no pronostica hacia dónde va tu glucosa.\n\nLas lecturas de más de 12 minutos se ignoran, y cada notificación se repite como máximo cada 15 minutos.\n\nAutoSugar muestra tus datos de Nightscout y no es un producto sanitario. No lo uses para decisiones de tratamiento. + Entendido + Esta instancia de Nightscout no define umbrales de glucosa, por lo que no se pueden enviar notificaciones para esta fuente. Configúra los umbrales en tu Nightscout — AutoSugar solo usa los que lee de allí. Alertas de glucosa Alertas de glucosa alta y baja con intervalo de repetición de 15 minutos Glucosa alta diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6e3076a..2edeb60 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -40,6 +40,12 @@ Alertes glycémie Alertes activées + + + Fonctionnement des notifications + AutoSugar vous avertit lorsqu’une mesure franchit un seuil que vous avez configuré sur votre propre instance Nightscout. AutoSugar ne définit jamais ces seuils — si votre Nightscout n’en définit aucun, rien n’est envoyé.\n\nLe graphique relie vos mesures réelles par des lignes droites. Rien n’est lissé ni prédit, et AutoSugar ne prévoit pas l’évolution de votre glycémie.\n\nLes mesures de plus de 12 minutes sont ignorées, et chaque notification se répète au plus toutes les 15 minutes.\n\nAutoSugar affiche vos données Nightscout et n’est pas un dispositif médical. Ne l’utilisez pas pour des décisions de traitement. + J’ai compris + Cette instance Nightscout ne définit aucun seuil de glycémie ; aucune notification ne peut donc être envoyée pour cette source. Définissez les seuils dans votre configuration Nightscout — AutoSugar n’utilise que ceux qu’il y lit. Alertes glycémie Alertes d\'hyperglycémie et d\'hypoglycémie avec intervalle de répétition de 15 minutes Glycémie élevée diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index ac6b7eb..6d52c24 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -40,6 +40,12 @@ ग्लूकोज़ अलर्ट अलर्ट चालू + + + सूचनाएँ कैसे काम करती हैं + AutoSugar आपको तफी सूचित करता है जब कोई रीडिंग उस सीमा को पार करती है जो आपने अपने Nightscout पर सेट की है। AutoSugar खुद ये सीमाएँ कभी नहीं तय करता — यदि आपका Nightscout कोई सीमा नहीं बताता, तो कुछ नहीं भेजा जाता।\n\nग्राफ आपकी वास्तविक रीडिंग को सीधी रेखाओं से जोड़ता है। कुछ भी स्मूथ या अनुमानित नहीं होता, और AutoSugar यह पूर्वानुमान नहीं लगाता कि ग्लूकोज़ किस ओर जा रहा है।\n\n12 मिनट से पुरानी रीडिंग नज़रअंदाज़ की जाती हैं, और हर सूचना अधिकतम हर 15 मिनट में दोहराई जाती है।\n\nAutoSugar आपका Nightscout डेटा दिखाता है और यह चिकित्सीय उपकरण नहीं है। इसे उपचार संबंधी निर्णयों के लिए उपयोग न करें। + समझ गया + यह Nightscout इंस्टेंस कोई ग्लूकोज़ सीमा नहीं बताता, इसलिए इस स्रोत के लिए सूचनाएँ नहीं भेजी जा सकतीं। अपने Nightscout में सीमाएँ सेट करें — AutoSugar केवल वहीं से पढ़ी सीमाओं का उपयोग करता है। ग्लूकोज़ अलर्ट 15 मिनट की पुनरावृत्ति के साथ उच्च और निम्न रक्त शर्करा अलर्ट उच्च रक्त शर्करा diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index cea247a..006d9c0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -40,6 +40,12 @@ Avvisi glicemia Avvisi attivi + + + Come funzionano le notifiche + AutoSugar ti avvisa quando una lettura supera una soglia che hai configurato sulla tua istanza Nightscout. AutoSugar non imposta mai quelle soglie — se il tuo Nightscout non ne definisce nessuna, non viene inviato nulla.\n\nIl grafico unisce le tue letture reali con linee rette. Nulla viene smussato o previsto, e AutoSugar non stima dove sta andando la tua glicemia.\n\nLe letture più vecchie di 12 minuti vengono ignorate e ogni notifica si ripete al massimo ogni 15 minuti.\n\nAutoSugar mostra i tuoi dati Nightscout e non è un dispositivo medico. Non usarlo per decisioni terapeutiche. + Ho capito + Questa istanza Nightscout non definisce soglie glicemiche, quindi per questa fonte non può essere inviata alcuna notifica. Imposta le soglie nella configurazione di Nightscout — AutoSugar usa soltanto quelle che legge da lì. Avvisi glicemia Avvisi per glicemia alta e bassa con intervallo di ripetizione di 15 minuti Glicemia alta diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 44211ff..6239a8d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -40,6 +40,12 @@ 血糖アラート アラート有効 + + + 通知の仕組み + AutoSugar は、ご自身の Nightscout で設定したしきい値を測定値が超えたときに通知します。しきい値を AutoSugar が決めることは一切ありません — Nightscout 側に設定がなければ、何も送信されません。\n\nグラフは実際の測定値を直線で結んだだけのものです。平滑化も予測も行わず、AutoSugar が今後の推移を予測することはありません。\n\n12 分より古い測定値は無視され、各通知の繰り返しは最短 15 分間隔です。\n\nAutoSugar は Nightscout のデータを表示するものであり、医療機器ではありません。治療の判断には使用しないでください。 + 了解 + この Nightscout には血糖値のしきい値が設定されていないため、このソースでは通知を送信できません。Nightscout の設定でしきい値を指定してください — AutoSugar はそこから読み取った値しか使いません。 血糖アラート 15分間隔の繰り返しで高血糖・低血糖アラート 高血糖 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index c8e41e4..252d899 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -40,6 +40,12 @@ Glucosemeldingen Meldingen aan + + + Hoe meldingen werken + AutoSugar waarschuwt je wanneer een meting een grenswaarde overschrijdt die je in je eigen Nightscout hebt ingesteld. AutoSugar stelt die grenswaarden nooit zelf in — definieert je Nightscout er geen, dan wordt er niets verzonden.\n\nDe grafiek verbindt je echte metingen met rechte lijnen. Er wordt niets afgevlakt of voorspeld, en AutoSugar voorspelt niet waar je glucose heen gaat.\n\nMetingen ouder dan 12 minuten worden genegeerd en elke melding herhaalt zich maximaal elke 15 minuten.\n\nAutoSugar toont je Nightscout-gegevens en is geen medisch hulpmiddel. Gebruik het niet voor behandelbeslissingen. + Duidelijk + Deze Nightscout-instantie definieert geen glucosegrenswaarden, dus voor deze bron kunnen geen meldingen worden verzonden. Stel de grenswaarden in je Nightscout-configuratie in — AutoSugar gebruikt alleen wat het daar leest. Glucosemeldingen Meldingen voor hoge en lage bloedglucose met herhalings-cooldown van 15 minuten Hoge glucose diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f42f9dd..b54539b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -40,6 +40,12 @@ Alertas de glicose Alertas ativados + + + Como funcionam as notificações + O AutoSugar avisa-te quando uma leitura cruza um limite que configuraste na tua própria instância Nightscout. O AutoSugar nunca define esses limites — se o teu Nightscout não definir nenhum, nada é enviado.\n\nO gráfico liga as tuas leituras reais com linhas retas. Nada é suavizado nem previsto, e o AutoSugar não projêta para onde vai a tua glicose.\n\nLeituras com mais de 12 minutos são ignoradas e cada notificação repete-se no máximo a cada 15 minutos.\n\nO AutoSugar mostra os teus dados do Nightscout e não é um dispositivo médico. Não o uses para decisões de tratamento. + Entendi + Esta instância Nightscout não define limites de glicose, por isso não podem ser enviadas notificações para esta fonte. Define os limites na configuração do teu Nightscout — o AutoSugar usa apenas os que lê de lá. Alertas de glicose Alertas de glicose alta e baixa com intervalo de repetição de 15 minutos Glicose alta diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 2a15fec..3b1eb5c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -40,6 +40,12 @@ 血糖提醒 提醒已开启 + + + 提醒如何工作 + 当读数越过你在自己的 Nightscout 上配置的阀值时,AutoSugar 会提醒你。AutoSugar 从不自行设定这些阀值 — 如果你的 Nightscout 未设定任何阀值,则不会发送任何提醒。\n\n图表仅用直线连接你的实际读数,不做平滑也不做预测,AutoSugar 不会推测你的血糖走势。\n\n超过 12 分钟的读数会被忽略,每条提醒最快每 15 分钟重复一次。\n\nAutoSugar 只是展示你的 Nightscout 数据,并非医疗器器械。请勿用于治疗决策。 + 知道了 + 此 Nightscout 未设定血糖阀值,因此无法为该数据源发送提醒。请在 Nightscout 配置中设定阀值 — AutoSugar 仅使用从那里读取的值。 血糖提醒 高低血糖提醒,15 分钟重复间隔 血糖偏高 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b328ef5..77b5fb4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,12 @@ Glucose alerts Alerts on + + + How notifications work + AutoSugar notifies you when a reading crosses a threshold you configured on your own Nightscout instance. AutoSugar never sets those thresholds itself — if your Nightscout defines none, nothing is sent.\n\nThe graph joins your actual readings with straight lines. Nothing is smoothed or predicted, and AutoSugar does not forecast where your glucose is heading.\n\nReadings older than 12 minutes are ignored, and each notification repeats at most every 15 minutes.\n\nAutoSugar displays your Nightscout data and is not a medical device. Do not use it for treatment decisions. + Got it + This Nightscout instance defines no glucose thresholds, so no notifications can be sent for this source. Set the thresholds in your Nightscout configuration — AutoSugar only ever uses the ones it reads from there. Glucose Alerts High and low blood glucose alerts with 15-minute repeat cooldown High glucose diff --git a/app/src/test/java/de/autosugar/data/repository/NightscoutRepositoryTest.kt b/app/src/test/java/de/autosugar/data/repository/NightscoutRepositoryTest.kt index fb6afd7..73fad1e 100644 --- a/app/src/test/java/de/autosugar/data/repository/NightscoutRepositoryTest.kt +++ b/app/src/test/java/de/autosugar/data/repository/NightscoutRepositoryTest.kt @@ -174,7 +174,7 @@ class NightscoutRepositoryTest { } @Test - fun `getThresholds uses default bgLow and bgHigh when null in response`() = runTest { + fun `getThresholds uses default bgLow and bgHigh for display when null in response`() = runTest { every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) every { mockFactory.get(any()) } returns mockApi coEvery { mockApi.getStatus(any()) } returns StatusDto( @@ -188,6 +188,82 @@ class NightscoutRepositoryTest { assertEquals(180, thresholds.bgHigh) } + @Test + fun `getThresholds exposes no notifiable bounds when Nightscout defines none`() = runTest { + // The display fallbacks above must never become notification bounds: AutoSugar deciding + // what counts as high or low for someone is exactly what it must not do. + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getStatus(any()) } returns StatusDto( + settings = SettingsDto( + thresholds = ThresholdsDto(bgHigh = null, bgTargetTop = 160.0, bgTargetBottom = 80.0, bgLow = null), + ), + ) + + val thresholds = repository.getThresholds("test-id").getOrThrow() + assertNull(thresholds.alertLow) + assertNull(thresholds.alertHigh) + assertFalse(thresholds.canNotify) + } + + @Test + fun `getThresholds exposes no notifiable bounds when the status carries no settings`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getStatus(any()) } returns StatusDto(settings = null) + + val thresholds = repository.getThresholds("test-id").getOrThrow() + assertFalse(thresholds.canNotify) + } + + @Test + fun `getThresholds passes through the bounds Nightscout reported`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getStatus(any()) } returns StatusDto( + settings = SettingsDto( + thresholds = ThresholdsDto(bgHigh = 200.0, bgTargetTop = 160.0, bgTargetBottom = 80.0, bgLow = 65.0), + ), + ) + + val thresholds = repository.getThresholds("test-id").getOrThrow() + assertEquals(65, thresholds.alertLow) + assertEquals(200, thresholds.alertHigh) + assertTrue(thresholds.canNotify) + } + + @Test + fun `getThresholds keeps a bound notifiable when only the other one is missing`() = runTest { + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile)) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getStatus(any()) } returns StatusDto( + settings = SettingsDto( + thresholds = ThresholdsDto(bgHigh = 190.0, bgTargetTop = null, bgTargetBottom = null, bgLow = null), + ), + ) + + val thresholds = repository.getThresholds("test-id").getOrThrow() + assertEquals(190, thresholds.alertHigh) + assertNull(thresholds.alertLow) + assertTrue(thresholds.canNotify) + } + + @Test + fun `getThresholdsFor works for a profile that is not saved yet`() = runTest { + // The edit screen tests a source before it exists in the data store. + every { mockDataStore.profilesFlow } returns flowOf(emptyList()) + every { mockFactory.get(any()) } returns mockApi + coEvery { mockApi.getStatus(any()) } returns StatusDto( + settings = SettingsDto( + thresholds = ThresholdsDto(bgHigh = 180.0, bgTargetTop = null, bgTargetBottom = null, bgLow = 70.0), + ), + ) + + val thresholds = repository.getThresholdsFor(profile).getOrThrow() + assertEquals(70, thresholds.alertLow) + assertEquals(180, thresholds.alertHigh) + } + @Test fun `getThresholds rounds fractional threshold values`() = runTest { // Regression for #13: Nightscout settings.thresholds can also carry non-integer values diff --git a/app/src/test/java/de/autosugar/ui/settings/ProfileEditViewModelTest.kt b/app/src/test/java/de/autosugar/ui/settings/ProfileEditViewModelTest.kt index 41f1764..0b7ed74 100644 --- a/app/src/test/java/de/autosugar/ui/settings/ProfileEditViewModelTest.kt +++ b/app/src/test/java/de/autosugar/ui/settings/ProfileEditViewModelTest.kt @@ -1,5 +1,6 @@ package de.autosugar.ui.settings +import de.autosugar.data.model.GlucoseThresholds import de.autosugar.data.model.GlucoseUnit import de.autosugar.data.model.NightscoutProfile import de.autosugar.data.model.ProfileIcon @@ -43,9 +44,17 @@ class ProfileEditViewModelTest { private lateinit var viewModel: ProfileEditViewModel + private val configuredThresholds = GlucoseThresholds( + bgLow = 70, bgTargetBottom = 80, bgTargetTop = 160, bgHigh = 180, + alertLow = 70, alertHigh = 180, + ) + @Before fun setUp() { Dispatchers.setMain(testDispatcher) + // testConnection also reports whether the instance defines any thresholds; default to + // one that does, so tests about other behaviour are unaffected. + coEvery { mockRepository.getThresholdsFor(any()) } returns Result.success(configuredThresholds) viewModel = ProfileEditViewModel(mockRepository) } @@ -106,6 +115,58 @@ class ProfileEditViewModelTest { assertFalse(viewModel.tokenOverpowered.value) } + @Test + fun `testConnection flags thresholdsMissing when Nightscout defines none`() = runTest { + val fakeEntry = de.autosugar.data.model.GlucoseEntry( + sgv = 110.0, direction = "Flat", dateIso = "2024-01-01T00:00:00Z", + delta = 2.0, dateMs = 0L, + ) + coEvery { mockRepository.testConnection(any()) } returns Result.success(fakeEntry) + coEvery { mockRepository.hasElevatedPermissions(any()) } returns false + coEvery { mockRepository.getThresholdsFor(any()) } returns Result.success( + configuredThresholds.copy(alertLow = null, alertHigh = null) + ) + + viewModel.testConnection() + advanceUntilIdle() + + assertTrue(viewModel.thresholdsMissing.value) + } + + @Test + fun `testConnection leaves thresholdsMissing false when Nightscout defines them`() = runTest { + val fakeEntry = de.autosugar.data.model.GlucoseEntry( + sgv = 110.0, direction = "Flat", dateIso = "2024-01-01T00:00:00Z", + delta = 2.0, dateMs = 0L, + ) + coEvery { mockRepository.testConnection(any()) } returns Result.success(fakeEntry) + coEvery { mockRepository.hasElevatedPermissions(any()) } returns false + + viewModel.testConnection() + advanceUntilIdle() + + assertFalse(viewModel.thresholdsMissing.value) + } + + @Test + fun `testConnection leaves thresholdsMissing false when the status call itself fails`() = runTest { + // A failed status read says nothing about whether thresholds are configured, so it must + // not be reported to the user as "none configured". + val fakeEntry = de.autosugar.data.model.GlucoseEntry( + sgv = 110.0, direction = "Flat", dateIso = "2024-01-01T00:00:00Z", + delta = 2.0, dateMs = 0L, + ) + coEvery { mockRepository.testConnection(any()) } returns Result.success(fakeEntry) + coEvery { mockRepository.hasElevatedPermissions(any()) } returns false + coEvery { mockRepository.getThresholdsFor(any()) } returns + Result.failure(Exception("status unreachable")) + + viewModel.testConnection() + advanceUntilIdle() + + assertFalse(viewModel.thresholdsMissing.value) + } + @Test fun `testConnection sets Error on failure`() = runTest { coEvery { mockRepository.testConnection(any()) } returns From 2c34307c8ddbe6eb2274f0ded0205c279b52f009 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Tue, 8 Sep 2026 12:31:44 +0200 Subject: [PATCH 4/5] chore(release): bump version to 1.2.8 --- CHANGELOG.md | 15 ++++++++++++--- app/build.gradle.kts | 4 ++-- docs/whatsnew/1.2.8-ar | 3 +++ docs/whatsnew/1.2.8-de-DE | 3 +++ docs/whatsnew/1.2.8-en-US | 3 +++ docs/whatsnew/1.2.8-es-ES | 3 +++ docs/whatsnew/1.2.8-fr-FR | 3 +++ docs/whatsnew/1.2.8-hi-IN | 3 +++ docs/whatsnew/1.2.8-it-IT | 3 +++ docs/whatsnew/1.2.8-ja-JP | 3 +++ docs/whatsnew/1.2.8-nl-NL | 3 +++ docs/whatsnew/1.2.8-pt-PT | 3 +++ docs/whatsnew/1.2.8-zh-CN | 3 +++ 13 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 docs/whatsnew/1.2.8-ar create mode 100644 docs/whatsnew/1.2.8-de-DE create mode 100644 docs/whatsnew/1.2.8-en-US create mode 100644 docs/whatsnew/1.2.8-es-ES create mode 100644 docs/whatsnew/1.2.8-fr-FR create mode 100644 docs/whatsnew/1.2.8-hi-IN create mode 100644 docs/whatsnew/1.2.8-it-IT create mode 100644 docs/whatsnew/1.2.8-ja-JP create mode 100644 docs/whatsnew/1.2.8-nl-NL create mode 100644 docs/whatsnew/1.2.8-pt-PT create mode 100644 docs/whatsnew/1.2.8-zh-CN diff --git a/CHANGELOG.md b/CHANGELOG.md index 7339c2e..0155c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,20 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.2.8] - 2026-09-08 + +### Removed + +- The "trending high/low" notifications are gone. They projected a reading 15 minutes ahead from a number AutoSugar derived itself, and nothing in the Nightscout payload carries a prediction — forecasting glucose is not something a display for Nightscout data should be doing. The trend arrow and delta on the car screen come from Nightscout and are unchanged + +### Added + +- Switching a source's notifications on now opens a short explainer first: where the thresholds come from, that the graph only joins real readings with straight lines and forecasts nothing, that stale readings are skipped and repeats are limited, and that AutoSugar is a display for Nightscout data rather than a medical device. The notification permission is requested after it, which is also the order Android recommends +- Testing a connection now warns when that Nightscout instance defines no glucose thresholds, so it is clear up front that no notifications can be sent for the source + ### Changed -- AutoSugar no longer forecasts glucose. The "trending high/low" notifications projected a reading 15 minutes ahead from a number the app derived itself; nothing in the Nightscout payload carries a prediction. The trend arrow and delta on the car screen come from Nightscout and are unchanged -- Notifications now fire only on thresholds the source's own Nightscout reports. Where it reports none, AutoSugar used to substitute 70/180 — values it chose — and notify on those; it now stays quiet and says so on the phone. The graph still falls back to a display range so it always has a scale to draw -- Switching a source's notifications on now explains how they work first: thresholds come from your own Nightscout, the graph joins real readings with straight lines and predicts nothing, stale readings are skipped, repeats are limited — and AutoSugar is a display for Nightscout data, not a medical device. The notification permission is requested after that explanation, which is also the order Android recommends +- Notifications now fire only on thresholds the source's own Nightscout reports. Where it reports none, AutoSugar used to substitute 70/180 — values it picked itself — and notify on those; it now stays quiet instead. The graph still falls back to a display range so it always has a scale to draw ## [1.2.7] - 2026-09-06 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f57d9bc..b7ae2d3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "de.autosugar" minSdk = 26 targetSdk = 37 - versionCode = 12 - versionName = "1.2.7" + versionCode = 13 + versionName = "1.2.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/docs/whatsnew/1.2.8-ar b/docs/whatsnew/1.2.8-ar new file mode 100644 index 0000000..81e73f8 --- /dev/null +++ b/docs/whatsnew/1.2.8-ar @@ -0,0 +1,3 @@ +أصبحت الإشعارات تعتمد فقط على الحدود المضبوطة في خادم Nightscout الخاص بك. وإن لم يحددها، يصمت AutoSugar ويُعلمك بدلاً من استخدام قيم اختارها بنفسه. + +وأُزيلت إشعارات الاتجاه: لم يعد AutoSugar يتنبأ بمسار الجلوكوز، وعند تشغيل الإشعارات يُشرح الآن كيف تعمل. diff --git a/docs/whatsnew/1.2.8-de-DE b/docs/whatsnew/1.2.8-de-DE new file mode 100644 index 0000000..5c112eb --- /dev/null +++ b/docs/whatsnew/1.2.8-de-DE @@ -0,0 +1,3 @@ +Benachrichtigungen richten sich jetzt ausschließlich nach den Grenzwerten in deinem eigenen Nightscout. Definiert es keine, bleibt AutoSugar still und sagt es dir, statt selbst gewählte Werte zu verwenden. + +Die Benachrichtigungen „Blutzucker steigt/fällt“ wurden entfernt: AutoSugar sagt keine Werte mehr voraus. Beim Aktivieren wird nun erklärt, wie Benachrichtigungen funktionieren. diff --git a/docs/whatsnew/1.2.8-en-US b/docs/whatsnew/1.2.8-en-US new file mode 100644 index 0000000..9cf33b5 --- /dev/null +++ b/docs/whatsnew/1.2.8-en-US @@ -0,0 +1,3 @@ +Notifications now come only from the thresholds set on your own Nightscout. If it defines none, AutoSugar stays quiet and tells you, instead of using values it picked itself. + +The "trending high/low" notifications are gone: AutoSugar no longer predicts where your glucose is heading. Turning notifications on now explains how they work. diff --git a/docs/whatsnew/1.2.8-es-ES b/docs/whatsnew/1.2.8-es-ES new file mode 100644 index 0000000..43badb9 --- /dev/null +++ b/docs/whatsnew/1.2.8-es-ES @@ -0,0 +1,3 @@ +Las notificaciones ahora se basan solo en los umbrales configurados en tu propio Nightscout. Si no define ninguno, AutoSugar se queda en silencio y te lo dice, en lugar de usar valores elegidos por sí mismo. + +Se han eliminado las notificaciones de tendencia: AutoSugar ya no predice hacia dónde va tu glucosa. Al activarlas se explica cómo funcionan. diff --git a/docs/whatsnew/1.2.8-fr-FR b/docs/whatsnew/1.2.8-fr-FR new file mode 100644 index 0000000..216a69b --- /dev/null +++ b/docs/whatsnew/1.2.8-fr-FR @@ -0,0 +1,3 @@ +Les notifications reposent désormais uniquement sur les seuils définis dans votre propre Nightscout. S’il n’en définit aucun, AutoSugar reste silencieux et vous le signale, au lieu d’utiliser des valeurs qu’il a choisies. + +Les notifications de tendance ont été supprimées : AutoSugar ne prédit plus l’évolution de votre glycémie. Leur activation explique maintenant leur fonctionnement. diff --git a/docs/whatsnew/1.2.8-hi-IN b/docs/whatsnew/1.2.8-hi-IN new file mode 100644 index 0000000..dbcbae4 --- /dev/null +++ b/docs/whatsnew/1.2.8-hi-IN @@ -0,0 +1,3 @@ +सूचनाएँ अब केवल आपके अपने Nightscout पर सेट सीमाओं पर आधारित हैं। यदि वह कोई सीमा नहीं बताता, तो AutoSugar अपनी चुनी हुई सीमा का उपयोग नहीं करता — बल्कि चुप रहता है और आपको बताता है। + +ट्रेंड सूचनाएँ हटा दी गई हैं: AutoSugar अब पूर्वानुमान नहीं लगाता। सूचनाएँ चालू करने पर अब उनकी कार्यप्रणाली समझाई जाती है। diff --git a/docs/whatsnew/1.2.8-it-IT b/docs/whatsnew/1.2.8-it-IT new file mode 100644 index 0000000..d1c2677 --- /dev/null +++ b/docs/whatsnew/1.2.8-it-IT @@ -0,0 +1,3 @@ +Le notifiche ora si basano solo sulle soglie impostate sul tuo Nightscout. Se non ne definisce nessuna, AutoSugar resta in silenzio e te lo dice, invece di usare valori scelti da sé. + +Le notifiche di tendenza sono state rimosse: AutoSugar non prevede più dove sta andando la glicemia. Attivandole viene spiegato come funzionano. diff --git a/docs/whatsnew/1.2.8-ja-JP b/docs/whatsnew/1.2.8-ja-JP new file mode 100644 index 0000000..40f3337 --- /dev/null +++ b/docs/whatsnew/1.2.8-ja-JP @@ -0,0 +1,3 @@ +通知はご自身の Nightscout で設定したしきい値のみに基づくようになりました。設定がなければ、独自の値を使わずに通知を送らず、そのことをお知らせします。 + +「上昇・下降傾向」の通知は廃止しました。AutoSugar は今後の推移を予測しません。通知を有効にすると仕組みを説明します。 diff --git a/docs/whatsnew/1.2.8-nl-NL b/docs/whatsnew/1.2.8-nl-NL new file mode 100644 index 0000000..dd97dcf --- /dev/null +++ b/docs/whatsnew/1.2.8-nl-NL @@ -0,0 +1,3 @@ +Meldingen komen nu alleen van de grenswaarden in je eigen Nightscout. Definieert het er geen, dan blijft AutoSugar stil en laat het je weten, in plaats van zelfgekozen waarden te gebruiken. + +De trendmeldingen zijn verwijderd: AutoSugar voorspelt niet meer waar je glucose heen gaat. Bij het aanzetten wordt nu uitgelegd hoe meldingen werken. diff --git a/docs/whatsnew/1.2.8-pt-PT b/docs/whatsnew/1.2.8-pt-PT new file mode 100644 index 0000000..190ebbb --- /dev/null +++ b/docs/whatsnew/1.2.8-pt-PT @@ -0,0 +1,3 @@ +As notificações passam a basear-se apenas nos limites definidos no teu próprio Nightscout. Se não definir nenhum, o AutoSugar fica em silêncio e avisa-te, em vez de usar valores escolhidos por si. + +As notificações de tendência foram removidas: o AutoSugar já não prevê para onde vai a tua glicose. Ao ativá-las, é explicado como funcionam. diff --git a/docs/whatsnew/1.2.8-zh-CN b/docs/whatsnew/1.2.8-zh-CN new file mode 100644 index 0000000..923693d --- /dev/null +++ b/docs/whatsnew/1.2.8-zh-CN @@ -0,0 +1,3 @@ +提醒现在仅依据你自己 Nightscout 上设定的阀值。如果它未设定任何阀值,AutoSugar 不会使用自行选定的数值,而是保持静默并告知你。 + +趋势提醒已移除:AutoSugar 不再预测血糖走势。开启提醒时会先说明其工作方式。 From 7c734db7b48451b4808d8a1cba26185e645569dd Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Tue, 8 Sep 2026 16:27:27 +0200 Subject: [PATCH 5/5] ci(google-play): fail loudly on missing release notes, tolerate no mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways this workflow could go wrong quietly. The job checks out the ref it was triggered on but takes its artifacts from the tag, so deploying a tag whose what's-new files are not on that ref made the glob match nothing, the `[ -f ]` guard skip every iteration, and the upload ship with an empty release-notes directory and a green tick. It now counts what it staged and fails with a message naming the version and the ref instead. mapping.txt was also downloaded in the same `gh release download` call as the AAB and passed to the action unconditionally, which made it a hard requirement that release.yml never guarantees — it attaches the file only `if [ -f ]`, so a build without one would have broken the deploy on an asset that is optional by design. It is fetched separately now, its absence is a notice rather than a failure, and mappingFile is left empty so the action skips it. Also correct the character-count instruction in the /release command: it told you to use `wc -m`, which silently counts bytes when the shell has no UTF-8 locale — the default for the non-interactive shells the command runs in on Windows. It reported ja-JP at 383 instead of 137, which would have had someone trimming what's-new text that was nowhere near Play's limit. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/commands/release.md | 2 +- .github/workflows/google-play.yml | 37 ++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 63086a0..25830b8 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -72,7 +72,7 @@ Only include changes that are visible or relevant to the user of the app itself. **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. +**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 with a UTF-8 locale forced — `LC_ALL=C.UTF-8 wc -m `. Bare `wc -m` silently counts *bytes* when the shell has no UTF-8 locale, which is the default for the non-interactive shells this command runs in on Windows, and `wc -c` always counts bytes. Either way multi-byte locales like `ar`/`hi-IN`/`ja-JP`/`zh-CN` measure two to three times their real length and you will trim text that was never over the limit. Cross-check anything close to the limit with `python -c "import io,sys;print(len(io.open(sys.argv[1],encoding='utf-8').read()))" `. 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/.github/workflows/google-play.yml b/.github/workflows/google-play.yml index 94ad6f0..e573a92 100644 --- a/.github/workflows/google-play.yml +++ b/.github/workflows/google-play.yml @@ -46,16 +46,34 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Download AAB and mapping from GitHub Release + - name: Download AAB from GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release download "${{ steps.resolve.outputs.tag }}" \ --repo ${{ github.repository }} \ --pattern "*.aab" \ - --pattern "mapping.txt" \ --dir release-assets + - name: Download ProGuard mapping if the release has one + id: mapping + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # release.yml attaches mapping.txt only when the build actually produced one, so a + # release without it is legitimate and must not fail the deploy — it just means Play + # gets no deobfuscation file. Downloading it in the same call as the AAB made it a + # hard requirement that the release workflow never guaranteed. + if gh release download "${{ steps.resolve.outputs.tag }}" \ + --repo ${{ github.repository }} \ + --pattern "mapping.txt" \ + --dir release-assets; then + echo "path=release-assets/mapping.txt" >> "$GITHUB_OUTPUT" + else + echo "::notice::No mapping.txt attached to ${{ steps.resolve.outputs.tag }} — uploading without a deobfuscation file." + echo "path=" >> "$GITHUB_OUTPUT" + fi + - name: Prepare what's new run: | VERSION="${{ steps.resolve.outputs.version }}" @@ -67,6 +85,18 @@ jobs: fi done + # This job checks out the ref it was triggered on, but takes the artifacts from the + # tag. Deploy a tag whose what's-new files are not on that ref — an older tag, or a + # release whose notes never reached this branch — and the glob above matches nothing, + # the [ -f ] guard skips every iteration without complaint, and the upload silently + # ships with no release notes at all. Refuse instead of guessing. + COUNT=$(find whatsnew -type f | wc -l) + if [ "$COUNT" -eq 0 ]; then + echo "::error::No what's-new files matched docs/whatsnew/${VERSION}-* in the ${{ github.ref_name }} checkout. Play would receive an empty release-notes directory." + exit 1 + fi + echo "Prepared $COUNT locale file(s) for $VERSION." + - name: Upload to Google Play uses: r0adkll/upload-google-play@v1 with: @@ -82,7 +112,8 @@ jobs: # the `|| 'internal'` as well as the input's own default. tracks: ${{ inputs.track || 'internal' }} whatsNewDirectory: whatsnew/ - mappingFile: release-assets/mapping.txt + # Empty when the release carried no mapping.txt; the action skips the input then. + mappingFile: ${{ steps.mapping.outputs.path }} # `changesNotSentForReview` is deliberately not set. Play rejects the parameter # outright ("Changes are sent for review automatically. The query parameter # changesNotSentForReview must not be set.") for any edit it has decided must be