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
12 changes: 12 additions & 0 deletions .claude/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ Interpret `$ARGUMENTS` as follows:
- Empty / blank → analyse the changelog and suggest a bump type (see below)
- Anything else → it is unusual; flag it and ask for confirmation or correction

## Step 0 — Target API level check

AutoSugar must always target the **newest stable Android API level** — Play reviews for this
app are slow, so waiting for Google's compliance deadline risks missing it (see
*Maintenance Policy: Target API Level* in `AGENTS.md`).

Check <https://developer.android.com/about/versions> for the newest stable API level and
compare it with `compileSdk` / `targetSdk` in `app/build.gradle.kts`. If a newer stable
level exists, tell the user before continuing and let them decide whether to bump it first
or release as-is. Do not bump it silently as part of the release — it needs its own review
of the platform's behaviour changes.

## Step 1 — Read current version

Read `app/build.gradle.kts` and extract:
Expand Down
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,27 @@ This document defines specific roles for AI agents or developers to ensure a mod
* **Tasks:** * Create a "Settings" UI (Jetpack Compose) to add, edit, or delete Nightscout sources.
* Manage local data storage (e.g., Room or DataStore) for persistent source configuration.
* Handle system-level language switching logic.

## Maintenance Policy: Target API Level

Google Play reviews for AutoSugar take a long time. Waiting for Google's compliance
deadline therefore risks a bottleneck where a mandatory `targetSdk` bump collides with a
slow review. The app must stay ahead of that deadline, not meet it.

* **Adopt every new stable Android API level as soon as it is released.** Bump both
`compileSdk` and `targetSdk` in `app/build.gradle.kts` — raising only `compileSdk`
does not satisfy Play's requirement.
* **Check regularly** — at minimum at the start of every release, and whenever a Google I/O
or Android release announcement lands. Sources:
* <https://developer.android.com/about/versions> — latest platform + API level
* <https://support.google.com/googleplay/android-developer/answer/11926878> — Play's
current target API level requirement and deadline
* **Before bumping**, read the *"Behavior changes: Apps targeting Android N or higher"*
page for the new level and verify the affected areas: foreground services, notifications,
background execution, orientation/adaptive layouts, and network security config.
* **After bumping**, run `./gradlew assembleDebug testDebugUnitTest lintDebug` and smoke-test
the car app in the DHU before releasing.

| Android | API level | Status |
|---------|-----------|--------|
| 17 | 37 | Current target (adopted 2026-09) |
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [1.2.7] - 2026-09-06

### Added

- Sources can now be switched off individually. The switch on each card in the source list enables or disables the source itself: a disabled source keeps its configuration on the phone but disappears from the car entirely and is no longer polled
- The source list marks every source whose alerts are on with a small bell, so the alert state stays visible now that it is set in the source's own screen

### Changed

- The switch in the source list no longer toggles glucose alerts. Alerts are set per source in that source's screen, which is also where the notification permission is requested
- The app now compiles against and targets Android 17 (API level 37), ahead of Google Play's compliance deadline rather than at it

### Fixed

- Turning alerts on from the source list now asks for notification permission, as the edit screen already did — a profile switched on there could previously never deliver anything, with nothing in the UI saying so
- Settings now shows a warning when a profile has alerts enabled but notifications are switched off for AutoSugar or its alert channel is blocked, with a button straight to the system settings. This covers permission that is denied for good (which can no longer be prompted for) and permission Android revokes on its own after months of not opening the app
- Glucose alerts now fire while AutoSugar is in the background and Android Auto is connected — the case they exist for. Alert polling used to run on the car session's lifecycle, which the host tears down as soon as another car app takes the screen, so alerts only ever appeared while the driver could already see the reading. It now runs in a foreground service tied to the car connection instead, and stops when Android Auto disconnects

## [1.2.6] - 2026-08-27

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ adb install app/build/outputs/apk/debug/app-debug.apk
```

Testing on Android Auto requires enabling **Unknown sources** in the Android Auto app (tap "Version" footer 10 times → Developer Settings).

## Target API Level

The app must always target the **newest stable Android API level** (currently API 37 /
Android 17) rather than waiting for Google Play's compliance deadline — Play reviews for
this app are slow, and a late bump risks missing the deadline. Check for a newer API level
at the start of every release; see the *Maintenance Policy: Target API Level* section in
`AGENTS.md` for the full checklist.
29 changes: 29 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,32 @@ adb -s <device-id> forward tcp:5277 tcp:5277
```bash
$ANDROID_HOME/extras/google/auto/desktop-head-unit
```

## Verifying background alerts

Alerts must fire while AutoSugar is *not* the visible car app — that is the whole point of them, and
it is the one thing the DHU makes easy to get wrong, because the app is on screen the entire time
you are looking at it.

1. Open AutoSugar on the DHU once, with at least one alert-enabled profile. This is what starts
`GlucoseMonitorService`; confirm it is running:

```bash
adb shell dumpsys activity services de.autosugar | grep -i "GlucoseMonitorService\|isForeground"
```

A silent "Monitoring glucose" notification also appears on the phone.

2. Switch the DHU to another app (Maps, or the launcher) so AutoSugar leaves the screen. The service
must stay in the list above — it is no longer tied to the session.

3. Drive the reading past a threshold (point the profile at a test Nightscout instance, or lower
`bgHigh` in Nightscout) and confirm the alert still appears as a heads-up notification on the car
screen while another app is in the foreground.

4. Disconnect the head unit (quit the DHU, unplug the phone). The service must stop within a few
seconds — re-run the `dumpsys` command and confirm it is gone, along with the phone notification.

Note that Android 15+ caps a `dataSync` foreground service at six hours per 24-hour window. On
timeout the app posts a "glucose monitoring stopped" alert and stops the service; reopening
AutoSugar on the car screen starts it again.
9 changes: 5 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ plugins {

android {
namespace = "de.autosugar"
compileSdk = 36
compileSdk = 37

defaultConfig {
applicationId = "de.autosugar"
minSdk = 26
targetSdk = 36
versionCode = 11
versionName = "1.2.6"
targetSdk = 37
versionCode = 12
versionName = "1.2.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down Expand Up @@ -92,6 +92,7 @@ dependencies {
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.lifecycle.runtime.compose)

// Networking
implementation(libs.retrofit.core)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class LoadingScreenTest {
@Before
fun setUp() {
carContext = TestCarContext.createCarContext(ApplicationProvider.getApplicationContext())
every { mockRepository.profilesFlow } returns emptyFlow()
every { mockRepository.enabledProfilesFlow } returns emptyFlow()
every { mockRepository.activeProfileId } returns MutableStateFlow(null)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class NoProfilesScreenTest {
@Before
fun setUp() {
carContext = TestCarContext.createCarContext(ApplicationProvider.getApplicationContext())
every { mockRepository.profilesFlow } returns emptyFlow()
every { mockRepository.enabledProfilesFlow } returns emptyFlow()
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class SourceSelectScreenTest {

@Test
fun onGetTemplate_returnsListTemplate() {
every { mockRepository.profilesFlow } returns emptyFlow()
every { mockRepository.enabledProfilesFlow } returns emptyFlow()

val controller = ScreenController(SourceSelectScreen(carContext, mockRepository) {})
controller.moveToState(Lifecycle.State.STARTED)
Expand All @@ -54,7 +54,7 @@ class SourceSelectScreenTest {

@Test
fun onGetTemplate_hasBackHeaderAction() {
every { mockRepository.profilesFlow } returns emptyFlow()
every { mockRepository.enabledProfilesFlow } returns emptyFlow()

val controller = ScreenController(SourceSelectScreen(carContext, mockRepository) {})
controller.moveToState(Lifecycle.State.STARTED)
Expand All @@ -65,7 +65,7 @@ class SourceSelectScreenTest {

@Test
fun onGetTemplate_withProfiles_showsCorrectItemCount() {
every { mockRepository.profilesFlow } returns flowOf(listOf(profile1, profile2))
every { mockRepository.enabledProfilesFlow } returns flowOf(listOf(profile1, profile2))

val controller = ScreenController(SourceSelectScreen(carContext, mockRepository) {})
controller.moveToState(Lifecycle.State.STARTED)
Expand All @@ -78,7 +78,7 @@ class SourceSelectScreenTest {

@Test
fun onGetTemplate_profileRowsShowDisplayName() {
every { mockRepository.profilesFlow } returns flowOf(listOf(profile1, profile2))
every { mockRepository.enabledProfilesFlow } returns flowOf(listOf(profile1, profile2))

val controller = ScreenController(SourceSelectScreen(carContext, mockRepository) {})
controller.moveToState(Lifecycle.State.STARTED)
Expand All @@ -93,6 +93,29 @@ class SourceSelectScreenTest {
assertTrue(titles?.contains("Bob") == true)
}

/**
* A source switched off on the phone must not be selectable in the car. The filtering lives
* in the repository, so what this pins down is that the screen reads the filtered flow and
* never falls back to the full profile list.
*/
@Test
fun onGetTemplate_omitsDisabledProfiles() {
every { mockRepository.enabledProfilesFlow } returns flowOf(listOf(profile1))

val controller = ScreenController(SourceSelectScreen(carContext, mockRepository) {})
controller.moveToState(Lifecycle.State.STARTED)

Thread.sleep(200)

val template = controller.getTemplatesReturned().last() as ListTemplate
val titles = template.singleList?.items
?.filterIsInstance<Row>()
?.map { it.title.toString() }
assertEquals(1, template.singleList?.items?.size)
assertTrue(titles?.contains("Alice") == true)
assertTrue(titles?.contains("Bob") == false)
}

/**
* The host rejects a list template outright once it exceeds its content limit, so the
* screen must clamp rather than render one row per profile.
Expand All @@ -104,7 +127,7 @@ class SourceSelectScreenTest {
val tooMany = (0..limit).map { index ->
profile1.copy(id = "id-$index", displayName = "Profile $index")
}
every { mockRepository.profilesFlow } returns flowOf(tooMany)
every { mockRepository.enabledProfilesFlow } returns flowOf(tooMany)

val controller = ScreenController(SourceSelectScreen(carContext, mockRepository) {})
controller.moveToState(Lifecycle.State.STARTED)
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
Expand Down Expand Up @@ -39,6 +40,17 @@
</intent-filter>
</service>

<!--
Runs the glucose alert polling for as long as Android Auto is connected, independently
of the car app's own (screen-bound) session. "dataSync" is the type that matches what
it actually does — periodically fetching readings from Nightscout — and is the only one
whose prerequisites the app meets without declaring permissions it has no use for.
-->
<service
android:name=".car.GlucoseMonitorService"
android:exported="false"
android:foregroundServiceType="dataSync" />

<!-- Required meta-data for Car App Library -->
<meta-data
android:name="androidx.car.app.minCarApiLevel"
Expand Down
21 changes: 15 additions & 6 deletions app/src/main/java/de/autosugar/car/AutoSugarSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,25 @@ class AutoSugarSession(
override fun onCreateScreen(intent: Intent): Screen {
if (!monitorStarted) {
monitorStarted = true
startBackgroundAlertMonitor()
startAlertMonitoring()
}
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() {
/**
* Hands alert polling to [GlucoseMonitorService], which outlives this session.
*
* The session's own lifecycle only covers the car app being *on screen* — the host stops and
* eventually destroys it once the user switches to Maps — so running the loop here meant alerts
* fired only while the driver could already see the readings. The service is tied to the car
* connection instead, and stops itself when Android Auto disconnects.
*/
private fun startAlertMonitoring() {
if (GlucoseMonitorService.start(carContext)) return

// The platform refused the foreground-service start (Android 12+ background-start rules).
// Fall back to polling on the session scope: alerts are then limited to the time the app is
// on screen, as before, which is degraded but still better than no alerting at all.
val monitor = BackgroundAlertMonitor(carContext, repository)
lifecycleScope.launch {
appPrefs.refreshIntervalSeconds.collectLatest { intervalSeconds ->
Expand Down
8 changes: 4 additions & 4 deletions app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package de.autosugar.car

import androidx.car.app.CarContext
import android.content.Context
import de.autosugar.data.model.GlucoseEntry
import de.autosugar.data.model.NightscoutProfile
import de.autosugar.data.repository.NightscoutRepository
Expand All @@ -17,10 +17,10 @@ import kotlinx.coroutines.flow.first
* data or cooldowns.
*/
class BackgroundAlertMonitor(
carContext: CarContext,
context: Context,
private val repository: NightscoutRepository,
) {
private val alertManager = GlucoseAlertManager(carContext)
private val alertManager = GlucoseAlertManager(context)
private val alertCooldownMs = 15 * 60_000L

// A reading older than this is considered stale (≥2 missed 5-min CGM readings) and never
Expand All @@ -37,7 +37,7 @@ class BackgroundAlertMonitor(
private val historyByProfile = mutableMapOf<String, List<GlucoseEntry>>()

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

Expand Down
34 changes: 34 additions & 0 deletions app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.app.NotificationManager
import android.content.Context
import androidx.car.app.notification.CarNotificationManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import de.autosugar.R
import de.autosugar.data.model.GlucoseUnit
import de.autosugar.data.model.MG_DL_PER_MMOL_L
Expand All @@ -19,6 +20,7 @@ class GlucoseAlertManager(private val context: Context) {
private const val NOTIF_LOW = 1002
private const val NOTIF_PREDICTED_HIGH = 1003
private const val NOTIF_PREDICTED_LOW = 1004
private const val NOTIF_MONITORING_STOPPED = 1005

/**
* Derives a stable notification id unique per (alert type, profile) so that two
Expand All @@ -27,6 +29,25 @@ class GlucoseAlertManager(private val context: Context) {
*/
internal fun notifId(base: Int, profileId: String): Int =
base * 100_000 + (profileId.hashCode() and 0xFFFF)

/**
* Whether an alert posted right now would actually reach the driver.
*
* Two independent switches can swallow every alert without any error surfacing: the
* app-level notification permission (denied by default on Android 13+, and revoked
* automatically for apps the user has not opened in months) and the alert channel itself,
* which the user can block on its own. Alerting is opt-in per profile, so a profile can
* sit with its toggle on for months while neither the phone nor the car ever shows
* anything — callers use this to say so instead of letting it fail silently.
*/
fun alertsDeliverable(context: Context): Boolean {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return false
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Null means the channel has not been created yet — the car app has never run on this
// install — which is not the same as the user having blocked it.
val channel = nm.getNotificationChannel(CHANNEL_ID) ?: return true
return channel.importance != NotificationManager.IMPORTANCE_NONE
}
}

private val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
Expand Down Expand Up @@ -76,6 +97,19 @@ class GlucoseAlertManager(private val context: Context) {
)
}

/**
* 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
* at once, so it carries no profile name and a single notification id.
*/
fun sendMonitoringStoppedAlert() {
post(
id = notifId(NOTIF_MONITORING_STOPPED, ""),
title = context.getString(R.string.notif_title_monitoring_stopped),
text = context.getString(R.string.notif_text_monitoring_stopped),
)
}

/** Prefixes the alert title with the profile name so the driver knows whose reading it is. */
private fun titled(profileName: String, titleRes: Int): String {
val title = context.getString(titleRes)
Expand Down
Loading
Loading