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
2 changes: 1 addition & 1 deletion .claude/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>`. 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()))" <file>`. 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
48 changes: 44 additions & 4 deletions .github/workflows/google-play.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand All @@ -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:
Expand All @@ -82,5 +112,15 @@ jobs:
# the `|| 'internal'` as well as the input's own default.
tracks: ${{ inputs.track || 'internal' }}
whatsNewDirectory: whatsnew/
mappingFile: release-assets/mapping.txt
changesNotSentForReview: true
# 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
# 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).
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ 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

- 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

### Added
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 = 37
versionCode = 12
versionName = "1.2.7"
versionCode = 13
versionName = "1.2.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down
65 changes: 19 additions & 46 deletions app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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<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.enabledProfilesFlow.first().filter { it.alertsEnabled }
Expand All @@ -44,53 +42,28 @@ 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()
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
}

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
}
}
21 changes: 3 additions & 18 deletions app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions app/src/main/java/de/autosugar/data/model/GlucoseThresholds.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlucoseThresholds> = 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<GlucoseThresholds> =
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,
)
}

Expand Down Expand Up @@ -170,4 +188,12 @@ class NightscoutRepository @Inject constructor(
suspend fun saveAll(profiles: List<NightscoutProfile>) {
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
}
}
Loading
Loading