diff --git a/.claude/commands/release.md b/.claude/commands/release.md index bfaa158..63086a0 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -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 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: diff --git a/AGENTS.md b/AGENTS.md index 8c3e383..4005c6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: + * — latest platform + API level + * — 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) | diff --git a/CHANGELOG.md b/CHANGELOG.md index a468580..e4043b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 167417b..c6c3298 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/TESTING.md b/TESTING.md index 8cadfe8..cc39ecd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -57,3 +57,32 @@ adb -s 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 90d0d3b..f57d9bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } @@ -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) diff --git a/app/src/androidTest/java/de/autosugar/car/LoadingScreenTest.kt b/app/src/androidTest/java/de/autosugar/car/LoadingScreenTest.kt index 191d758..84b8aa3 100644 --- a/app/src/androidTest/java/de/autosugar/car/LoadingScreenTest.kt +++ b/app/src/androidTest/java/de/autosugar/car/LoadingScreenTest.kt @@ -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) } diff --git a/app/src/androidTest/java/de/autosugar/car/NoProfilesScreenTest.kt b/app/src/androidTest/java/de/autosugar/car/NoProfilesScreenTest.kt index f4c32e1..b6f359c 100644 --- a/app/src/androidTest/java/de/autosugar/car/NoProfilesScreenTest.kt +++ b/app/src/androidTest/java/de/autosugar/car/NoProfilesScreenTest.kt @@ -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 diff --git a/app/src/androidTest/java/de/autosugar/car/SourceSelectScreenTest.kt b/app/src/androidTest/java/de/autosugar/car/SourceSelectScreenTest.kt index de80745..b91a7fc 100644 --- a/app/src/androidTest/java/de/autosugar/car/SourceSelectScreenTest.kt +++ b/app/src/androidTest/java/de/autosugar/car/SourceSelectScreenTest.kt @@ -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) @@ -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) @@ -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) @@ -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) @@ -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() + ?.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. @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6f7470a..d3d0051 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + + + diff --git a/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt b/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt index f05fb96..0ef2644 100644 --- a/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt +++ b/app/src/main/java/de/autosugar/car/BackgroundAlertMonitor.kt @@ -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 @@ -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 @@ -37,7 +37,7 @@ class BackgroundAlertMonitor( private val historyByProfile = mutableMapOf>() 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() } diff --git a/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt b/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt index 544b2a9..ac7ecf7 100644 --- a/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt +++ b/app/src/main/java/de/autosugar/car/GlucoseAlertManager.kt @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/app/src/main/java/de/autosugar/car/GlucoseMonitorService.kt b/app/src/main/java/de/autosugar/car/GlucoseMonitorService.kt new file mode 100644 index 0000000..968af21 --- /dev/null +++ b/app/src/main/java/de/autosugar/car/GlucoseMonitorService.kt @@ -0,0 +1,181 @@ +package de.autosugar.car + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.annotation.RequiresApi +import androidx.car.app.connection.CarConnection +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Observer +import dagger.hilt.android.AndroidEntryPoint +import de.autosugar.R +import de.autosugar.data.repository.NightscoutRepository +import de.autosugar.data.storage.AppPreferencesDataStore +import de.autosugar.ui.MainActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Polls every alert-enabled profile for as long as Android Auto is *connected*, rather than only + * while AutoSugar happens to be the car app currently on screen. + * + * The alert loop used to live on [AutoSugarSession]'s lifecycle scope, which the host tears down as + * soon as the user switches to Maps or any other car app. That inverted the feature: alerts fired + * only while the driver was already looking at the readings, and went silent exactly when a + * notification was the only way to surface a high or low. Polling therefore runs here, in a started + * foreground service whose lifetime is tied to the car *connection* reported by [CarConnection], + * not to screen visibility. + */ +@AndroidEntryPoint +class GlucoseMonitorService : Service() { + + companion object { + private const val CHANNEL_ID = "glucose_monitor" + private const val ONGOING_NOTIF_ID = 1 + + /** + * Starts the monitor, returning whether it was accepted. Android 12+ rejects + * foreground-service starts it considers to come from the background, so callers are + * expected to handle `false` rather than lose alerting outright. + */ + fun start(context: Context): Boolean { + val appContext = context.applicationContext + val intent = Intent(appContext, GlucoseMonitorService::class.java) + return try { + ContextCompat.startForegroundService(appContext, intent) + true + } catch (_: IllegalStateException) { + // ForegroundServiceStartNotAllowedException (API 31+) extends IllegalStateException. + false + } catch (_: SecurityException) { + // Foreground service type not permitted for this app. + false + } + } + } + + @Inject lateinit var repository: NightscoutRepository + @Inject lateinit var appPrefs: AppPreferencesDataStore + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var monitoring = false + + private val carConnection by lazy { CarConnection(this) } + + private val connectionObserver = Observer { type -> + // The head unit is gone, so there is no driver left to alert: stop rather than keep + // polling Nightscout — and holding a foreground service — for the rest of the day. + if (type == CarConnection.CONNECTION_TYPE_NOT_CONNECTED) stopSelf() + } + + override fun onBind(intent: Intent): IBinder? = null + + override fun onCreate() { + super.onCreate() + createChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // First thing, before anything that could throw or block: the system kills the process if a + // service started with startForegroundService() has not posted its notification within ~5s. + ServiceCompat.startForeground( + this, + ONGOING_NOTIF_ID, + buildOngoingNotification(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, + ) + + // Repeated starts (the session is recreated every time the user re-opens the car app) must + // not stack up a second polling loop on top of the running one. + if (!monitoring) { + monitoring = true + carConnection.type.observeForever(connectionObserver) + startMonitoring() + } + return START_STICKY + } + + /** + * Android 15+ caps a `dataSync` foreground service at six hours per 24-hour window. Stop + * cleanly when that budget runs out — the system raises a fatal `RemoteServiceException` + * otherwise — and say so on the car screen, so alerting never goes quiet unannounced. + */ + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + override fun onTimeout(startId: Int, fgsType: Int) { + GlucoseAlertManager(this).sendMonitoringStoppedAlert() + stopSelf() + } + + override fun onDestroy() { + if (monitoring) carConnection.type.removeObserver(connectionObserver) + scope.cancel() + super.onDestroy() + } + + private fun startMonitoring() { + // One monitor instance for the life of the service, so per-profile alert cooldowns survive + // a refresh-interval change restarting the loop below. + val monitor = BackgroundAlertMonitor(this, repository) + scope.launch { + appPrefs.refreshIntervalSeconds.collectLatest { intervalSeconds -> + while (isActive) { + monitor.checkAll() + delay(intervalSeconds * 1000L) + } + } + } + } + + private fun createChannel() { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.notif_channel_monitor_name), + // The ongoing notification is a status indicator, not an alert: keep it silent so + // it never competes with the glucose alerts themselves. + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.notif_channel_monitor_desc) + setShowBadge(false) + } + ) + } + + private fun buildOngoingNotification(): Notification = + NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_profile_medical) + .setContentTitle(getString(R.string.notif_monitor_title)) + .setContentText(getString(R.string.notif_monitor_text)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setContentIntent( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + ) + .build() +} diff --git a/app/src/main/java/de/autosugar/car/GlucoseScreen.kt b/app/src/main/java/de/autosugar/car/GlucoseScreen.kt index aca050b..f46562f 100644 --- a/app/src/main/java/de/autosugar/car/GlucoseScreen.kt +++ b/app/src/main/java/de/autosugar/car/GlucoseScreen.kt @@ -80,16 +80,17 @@ class GlucoseScreen( init { lifecycleScope.launch { - repository.profilesFlow.collect { updated -> + repository.enabledProfilesFlow.collect { updated -> if (updated.isEmpty()) { - // All profiles were removed — return to the no-profiles screen instead - // of rendering an orphaned reading. + // Every source was removed or disabled — return to the no-profiles screen + // instead of rendering an orphaned reading. replaceStackWith(NoProfilesScreen(carContext, repository, appPrefs)) return@collect } profiles = updated if (profiles.none { it.id == activeProfileId }) { - // The active profile was deleted; fall back to the first remaining one. + // The active profile was deleted or disabled; fall back to the first + // remaining one. switchTo(profiles.first().id) } else { invalidate() diff --git a/app/src/main/java/de/autosugar/car/LoadingScreen.kt b/app/src/main/java/de/autosugar/car/LoadingScreen.kt index 1986351..94e3542 100644 --- a/app/src/main/java/de/autosugar/car/LoadingScreen.kt +++ b/app/src/main/java/de/autosugar/car/LoadingScreen.kt @@ -19,7 +19,7 @@ class LoadingScreen( init { lifecycleScope.launch { - val profiles = repository.profilesFlow.first() + val profiles = repository.enabledProfilesFlow.first() val nextScreen = when { profiles.isEmpty() -> NoProfilesScreen(carContext, repository, appPrefs) else -> { diff --git a/app/src/main/java/de/autosugar/car/NoProfilesScreen.kt b/app/src/main/java/de/autosugar/car/NoProfilesScreen.kt index 4a5abfe..f059bb3 100644 --- a/app/src/main/java/de/autosugar/car/NoProfilesScreen.kt +++ b/app/src/main/java/de/autosugar/car/NoProfilesScreen.kt @@ -14,7 +14,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** - * Shown while no Nightscout source is configured. + * Shown while the car has no source to display — none configured, or all of them disabled on + * the phone. * * The body is a plain status statement: it must not tell the driver to pick up their * phone, because setup is a phone-side task and the car app quality guidelines forbid @@ -30,7 +31,7 @@ class NoProfilesScreen( init { lifecycleScope.launch { - val profiles = repository.profilesFlow.first { it.isNotEmpty() } + val profiles = repository.enabledProfilesFlow.first { it.isNotEmpty() } val id = profiles.first().id repository.setActiveProfile(id) replaceStackWith(GlucoseScreen(carContext, repository, appPrefs, id)) diff --git a/app/src/main/java/de/autosugar/car/SourceSelectScreen.kt b/app/src/main/java/de/autosugar/car/SourceSelectScreen.kt index 0a851e9..4d8de37 100644 --- a/app/src/main/java/de/autosugar/car/SourceSelectScreen.kt +++ b/app/src/main/java/de/autosugar/car/SourceSelectScreen.kt @@ -23,7 +23,7 @@ class SourceSelectScreen( init { lifecycleScope.launch { - repository.profilesFlow.collect { updated -> + repository.enabledProfilesFlow.collect { updated -> profiles = updated invalidate() } diff --git a/app/src/main/java/de/autosugar/data/model/NightscoutProfile.kt b/app/src/main/java/de/autosugar/data/model/NightscoutProfile.kt index 57fc48a..4443523 100644 --- a/app/src/main/java/de/autosugar/data/model/NightscoutProfile.kt +++ b/app/src/main/java/de/autosugar/data/model/NightscoutProfile.kt @@ -34,5 +34,11 @@ data class NightscoutProfile( val apiToken: String, val unit: GlucoseUnit = GlucoseUnit.MG_DL, val icon: ProfileIcon = ProfileIcon.PERSON, + /** + * Whether the source is shown in the car at all. A disabled profile stays configured on the + * phone but is invisible to every car screen and is never polled — alerts included, since + * [alertsEnabled] only decides whether an *active* source may raise one. + */ + val enabled: Boolean = true, val alertsEnabled: Boolean = false, ) 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 e4e724e..dc2a33f 100644 --- a/app/src/main/java/de/autosugar/data/repository/NightscoutRepository.kt +++ b/app/src/main/java/de/autosugar/data/repository/NightscoutRepository.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton import kotlin.math.roundToInt @@ -20,8 +21,17 @@ class NightscoutRepository @Inject constructor( private val dataStore: ProfileDataStore, private val apiFactory: NightscoutApiFactory, ) { + /** Every configured profile, enabled or not. The phone-side settings UI edits this list. */ val profilesFlow: Flow> = dataStore.profilesFlow + /** + * The profiles the car is allowed to see. Every car screen and the alert monitor read this + * instead of [profilesFlow], so disabling a source on the phone removes it from the car + * outright rather than leaving it selectable but inert. + */ + val enabledProfilesFlow: Flow> = + dataStore.profilesFlow.map { profiles -> profiles.filter { it.enabled } } + private val _activeProfileId = MutableStateFlow(null) val activeProfileId: StateFlow = _activeProfileId.asStateFlow() @@ -148,6 +158,15 @@ class NightscoutRepository @Inject constructor( } } + /** Atomically shows or hides a single profile in the car, leaving its configuration intact. */ + suspend fun setProfileEnabled(id: String, enabled: Boolean) { + dataStore.update { profiles -> + profiles.map { if (it.id == id) it.copy(enabled = enabled) else it } + } + // A source that just disappeared from the car must not stay the selected one. + if (!enabled && _activeProfileId.value == id) _activeProfileId.value = null + } + suspend fun saveAll(profiles: List) { dataStore.save(profiles) } diff --git a/app/src/main/java/de/autosugar/data/storage/ProfileSerializer.kt b/app/src/main/java/de/autosugar/data/storage/ProfileSerializer.kt index 00b5902..1049cfd 100644 --- a/app/src/main/java/de/autosugar/data/storage/ProfileSerializer.kt +++ b/app/src/main/java/de/autosugar/data/storage/ProfileSerializer.kt @@ -36,6 +36,8 @@ internal data class NightscoutProfileJson( val apiToken: String, val unit: String, val icon: String = de.autosugar.data.model.ProfileIcon.PERSON.name, + // Defaults to true so profiles written before the field existed stay visible in the car. + val enabled: Boolean = true, val alertsEnabled: Boolean = false, ) @@ -46,6 +48,7 @@ private fun NightscoutProfile.toJson() = NightscoutProfileJson( apiToken = apiToken, unit = unit.name, icon = icon.name, + enabled = enabled, alertsEnabled = alertsEnabled, ) @@ -58,5 +61,6 @@ private fun NightscoutProfileJson.toModel() = NightscoutProfile( .getOrDefault(de.autosugar.data.model.GlucoseUnit.MG_DL), icon = runCatching { de.autosugar.data.model.ProfileIcon.valueOf(icon) } .getOrDefault(de.autosugar.data.model.ProfileIcon.PERSON), + enabled = enabled, alertsEnabled = alertsEnabled, ) diff --git a/app/src/main/java/de/autosugar/ui/settings/NotificationSettings.kt b/app/src/main/java/de/autosugar/ui/settings/NotificationSettings.kt new file mode 100644 index 0000000..9159b42 --- /dev/null +++ b/app/src/main/java/de/autosugar/ui/settings/NotificationSettings.kt @@ -0,0 +1,33 @@ +package de.autosugar.ui.settings + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings + +/** + * Opens AutoSugar's notification settings, where both the app-level switch and the glucose alert + * channel can be turned back on. + * + * This is the only route back once notifications are off: a runtime permission the user has denied + * twice can no longer be requested — `launch()` returns "denied" without ever showing a dialog — + * and a channel the user blocked by hand cannot be unblocked by the app at all. + */ +internal fun openNotificationSettings(context: Context) { + val appNotifications = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(appNotifications) + } catch (_: ActivityNotFoundException) { + // Some OEM builds do not expose the per-app notification screen; the app details page + // always exists and gets the user to the same switches in one more tap. + context.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } +} 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 c922967..b1baa3d 100644 --- a/app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt +++ b/app/src/main/java/de/autosugar/ui/settings/ProfileEditScreen.kt @@ -37,8 +37,10 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -46,7 +48,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -83,13 +87,31 @@ fun ProfileEditScreen( val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current + var notificationsDenied by remember { mutableStateOf(false) } + val notificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> // Without notification permission, alerts can never be delivered. Turn the // toggle back off so it reflects reality instead of staying on while alerts // silently never fire. - if (!granted) viewModel.alertsEnabled.value = false + if (!granted) { + viewModel.alertsEnabled.value = false + notificationsDenied = true + } + } + + // A permission denied twice can no longer be prompted for — launch() returns "denied" without + // showing anything — so the toggle would otherwise just flick back off for no visible reason. + LaunchedEffect(notificationsDenied) { + if (!notificationsDenied) return@LaunchedEffect + notificationsDenied = false + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.msg_notifications_denied), + actionLabel = context.getString(R.string.btn_settings), + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) openNotificationSettings(context) } LaunchedEffect(uiState) { 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 c2c112e..8ba568c 100644 --- a/app/src/main/java/de/autosugar/ui/settings/ProfileEditViewModel.kt +++ b/app/src/main/java/de/autosugar/ui/settings/ProfileEditViewModel.kt @@ -38,6 +38,10 @@ class ProfileEditViewModel @Inject constructor( private var editingId: String? = null + // Not editable here — the source on/off switch lives in the profile list — but it has to be + // carried through a save, or editing a disabled profile would silently switch it back on. + private var editingEnabled: Boolean = true + private val _uiState = MutableStateFlow(ProfileEditUiState.Idle) val uiState: StateFlow = _uiState.asStateFlow() @@ -53,6 +57,7 @@ class ProfileEditViewModel @Inject constructor( apiToken.value = profile.apiToken unit.value = profile.unit icon.value = profile.icon + editingEnabled = profile.enabled alertsEnabled.value = profile.alertsEnabled } } @@ -105,6 +110,7 @@ class ProfileEditViewModel @Inject constructor( apiToken = apiToken.value.trim(), unit = unit.value, icon = icon.value, + enabled = editingEnabled, alertsEnabled = alertsEnabled.value, ) } diff --git a/app/src/main/java/de/autosugar/ui/settings/SettingsScreen.kt b/app/src/main/java/de/autosugar/ui/settings/SettingsScreen.kt index 1e1b611..8529951 100644 --- a/app/src/main/java/de/autosugar/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/autosugar/ui/settings/SettingsScreen.kt @@ -10,12 +10,17 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -29,6 +34,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -40,17 +46,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import de.autosugar.BuildConfig import de.autosugar.R +import de.autosugar.car.GlucoseAlertManager import de.autosugar.data.model.NightscoutProfile import kotlin.math.roundToInt -// Items before the profile list in the LazyColumn (RefreshSection + Divider) +// Items before the profile list in the LazyColumn (RefreshSection + Divider), plus the +// alerts-blocked banner when it is showing. Drag-and-drop maps list indices to profile +// indices through this, so it has to follow the banner. private const val HEADER_COUNT = 2 @OptIn(ExperimentalMaterial3Api::class) @@ -67,6 +80,29 @@ fun SettingsScreen( val localProfiles = remember { mutableStateListOf() } var isDragging by remember { mutableStateOf(false) } + val context = LocalContext.current + var alertsDeliverable by remember { mutableStateOf(true) } + + // Notifications can be switched off long after alerts were enabled — by hand, or by Android + // revoking permissions for an app that has not been opened in months. Re-check on every resume + // so the banner also clears the moment the user comes back from the system settings screen. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + alertsDeliverable = GlucoseAlertManager.alertsDeliverable(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + // Only a source that is actually shown in the car can raise an alert, so a disabled one + // being unable to notify is not worth warning about. + val showAlertsBlocked = + !alertsDeliverable && localProfiles.any { it.enabled && it.alertsEnabled } + val headerCount = if (showAlertsBlocked) HEADER_COUNT + 1 else HEADER_COUNT + // Keep local list in sync with repository, but not during an active drag LaunchedEffect(profiles) { if (!isDragging) { @@ -75,8 +111,8 @@ fun SettingsScreen( } } - val dragState = remember(lazyListState) { - DragDropState(lazyListState, HEADER_COUNT) { from, to -> + val dragState = remember(lazyListState, headerCount) { + DragDropState(lazyListState, headerCount) { from, to -> val item = localProfiles.removeAt(from) localProfiles.add(to, item) } @@ -139,6 +175,11 @@ fun SettingsScreen( ), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + if (showAlertsBlocked) { + item { + AlertsBlockedBanner(onOpenSettings = { openNotificationSettings(context) }) + } + } item { RefreshIntervalSection( currentSeconds = refreshInterval, @@ -172,8 +213,8 @@ fun SettingsScreen( translationY = if (isDraggingThis) dragState.dragOffset else 0f shadowElevation = if (isDraggingThis) 16f else 0f }, - onAlertsToggled = { enabled -> - viewModel.setAlertsEnabled(profile.id, enabled) + onEnabledToggled = { enabled -> + viewModel.setProfileEnabled(profile.id, enabled) }, onClick = { onEditProfile(profile.id) }, ) @@ -198,6 +239,46 @@ fun SettingsScreen( } } +@Composable +private fun AlertsBlockedBanner(onOpenSettings: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Default.Warning, contentDescription = null) + Text( + text = stringResource(R.string.label_alerts_blocked_title), + style = MaterialTheme.typography.titleSmall, + ) + } + Text( + text = stringResource(R.string.label_alerts_blocked_text), + style = MaterialTheme.typography.bodySmall, + ) + Button( + onClick = onOpenSettings, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.btn_open_notification_settings)) + } + } + } +} + private val REFRESH_OPTIONS = listOf( 30 to R.string.label_refresh_30s, 60 to R.string.label_refresh_1min, @@ -230,7 +311,7 @@ private fun ProfileCard( profile: NightscoutProfile, isDragging: Boolean, modifier: Modifier = Modifier, - onAlertsToggled: (Boolean) -> Unit, + onEnabledToggled: (Boolean) -> Unit, onClick: () -> Unit, ) { val elevation by animateDpAsState( @@ -247,26 +328,43 @@ private fun ProfileCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { + // A disabled source stays in the list, and editable, but is dimmed to match the + // fact that the car cannot see it. + val contentAlpha = if (profile.enabled) 1f else 0.38f Icon( painter = painterResource(profile.icon.resId), contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha), ) Column(modifier = Modifier.weight(1f)) { - Text( - text = profile.displayName, - style = MaterialTheme.typography.titleMedium, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = profile.displayName, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), + ) + if (profile.alertsEnabled) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = stringResource(R.string.label_alerts_on), + tint = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha), + modifier = Modifier.size(16.dp), + ) + } + } Text( text = profile.baseUrl, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha), maxLines = 1, ) } Switch( - checked = profile.alertsEnabled, - onCheckedChange = onAlertsToggled, + checked = profile.enabled, + onCheckedChange = onEnabledToggled, ) } } diff --git a/app/src/main/java/de/autosugar/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/autosugar/ui/settings/SettingsViewModel.kt index a97359c..b977dca 100644 --- a/app/src/main/java/de/autosugar/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/autosugar/ui/settings/SettingsViewModel.kt @@ -24,8 +24,9 @@ class SettingsViewModel @Inject constructor( val refreshIntervalSeconds: StateFlow = appPrefs.refreshIntervalSeconds .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 60) - fun setAlertsEnabled(id: String, enabled: Boolean) { - viewModelScope.launch { repository.setAlertsEnabled(id, enabled) } + /** Shows or hides a source in the car. Alerts are a separate switch, on the edit screen. */ + fun setProfileEnabled(id: String, enabled: Boolean) { + viewModelScope.launch { repository.setProfileEnabled(id, enabled) } } fun saveOrder(ordered: List) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d798e49..5c7115a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -39,6 +39,7 @@ تنبيهات الجلوكوز + التنبيهات مفعّلة تنبيهات الجلوكوز تنبيهات ارتفاع وانخفاض سكر الدم مع فترة راحة تكرار 15 دقيقة ارتفاع السكر @@ -47,6 +48,21 @@ السكر في انخفاض متوقع خلال 15 دقيقة: %s + + المراقبة + إشعار حالة صامت يظهر أثناء مراقبة الجلوكوز في السيارة + جارٍ مراقبة الجلوكوز + تبقى التنبيهات نشطة طالما كان Android Auto متصلاً + توقفت مراقبة الجلوكوز + افتح AutoSugar على شاشة السيارة لاستئناف التنبيهات + + + تعذر تسليم التنبيهات + الإشعارات معطّلة لتطبيق AutoSugar، لذلك لن تظهر تنبيهات الجلوكوز لا في السيارة ولا على الهاتف. + فتح إعدادات الإشعارات + تحتاج التنبيهات إلى إذن إرسال الإشعارات + الإعدادات + فترة التحديث 30 ثانية diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d0d7791..b634f50 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -39,6 +39,7 @@ Glukose-Alarme + Alarme aktiv Glukose-Alarme Alarme bei hohem und niedrigem Blutzucker mit 15-Minuten-Wiederholungsintervall Hoher Blutzucker @@ -47,6 +48,21 @@ Blutzucker fällt Voraussichtlich in 15 Min.: %s + + Überwachung + Stille Statusmeldung, solange die Glukose im Auto überwacht wird + Glukose wird überwacht + Alarme bleiben aktiv, solange Android Auto verbunden ist + Glukoseüberwachung beendet + AutoSugar auf dem Autobildschirm öffnen, um Alarme fortzusetzen + + + Alarme können nicht zugestellt werden + Benachrichtigungen für AutoSugar sind deaktiviert. Glukose-Alarme erscheinen weder im Auto noch auf dem Telefon. + Benachrichtigungseinstellungen öffnen + Alarme benötigen die Berechtigung für Benachrichtigungen + Einstellungen + Aktualisierungsintervall 30 Sek. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 6dd04a8..2fb0603 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -39,6 +39,7 @@ Alertas de glucosa + Alertas activadas Alertas de glucosa Alertas de glucosa alta y baja con intervalo de repetición de 15 minutos Glucosa alta @@ -47,6 +48,21 @@ Glucosa en tendencia baja Predicción en 15 min: %s + + Monitorización + Notificación de estado silenciosa mientras se monitoriza la glucosa en el coche + Monitorizando la glucosa + Las alertas siguen activas mientras Android Auto esté conectado + Monitorización de glucosa detenida + Abre AutoSugar en la pantalla del coche para reanudar las alertas + + + Las alertas no se pueden entregar + Las notificaciones de AutoSugar están desactivadas, así que las alertas de glucosa no aparecerán ni en el coche ni en el teléfono. + Abrir ajustes de notificaciones + Las alertas necesitan permiso para enviar notificaciones + Ajustes + Intervalo de actualización 30 seg diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8f20b65..6484073 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -39,6 +39,7 @@ Alertes glycémie + Alertes activées Alertes glycémie Alertes d\'hyperglycémie et d\'hypoglycémie avec intervalle de répétition de 15 minutes Glycémie élevée @@ -47,6 +48,21 @@ Glycémie en baisse Prévision dans 15 min : %s + + Surveillance + Notification d’état silencieuse affichée pendant la surveillance du glucose en voiture + Surveillance du glucose + Les alertes restent actives tant qu’Android Auto est connecté + Surveillance du glucose arrêtée + Ouvrez AutoSugar sur l’écran de la voiture pour reprendre les alertes + + + Les alertes ne peuvent pas être remises + Les notifications sont désactivées pour AutoSugar : les alertes de glucose n’apparaîtront ni dans la voiture ni sur le téléphone. + Ouvrir les paramètres de notification + Les alertes nécessitent l’autorisation d’envoyer des notifications + Paramètres + Intervalle d\'actualisation 30 sec diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index ded3400..0951dbc 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -39,6 +39,7 @@ ग्लूकोज़ अलर्ट + अलर्ट चालू ग्लूकोज़ अलर्ट 15 मिनट की पुनरावृत्ति के साथ उच्च और निम्न रक्त शर्करा अलर्ट उच्च रक्त शर्करा @@ -47,6 +48,21 @@ ग्लूकोज़ घट रहा है 15 मिनट में अनुमानित: %s + + निगरानी + कार में ग्लूकोज़ की निगरानी के दौरान दिखने वाली मूक स्थिति सूचना + ग्लूकोज़ की निगरानी जारी + Android Auto कनेक्ट रहने तक अलर्ट सक्रिय रहते हैं + ग्लूकोज़ निगरानी बंद हुई + अलर्ट फिर से शुरू करने के लिए कार स्क्रीन पर AutoSugar खोलें + + + अलर्ट नहीं भेजे जा सकते + AutoSugar के लिए सूचनाएँ बंद हैं, इसलिए ग्लूकोज़ अलर्ट न कार में दिखेंगे और न फ़ोन पर। + सूचना सेटिंग्स खोलें + अलर्ट के लिए सूचना अनुमति ज़रूरी है + सेटिंग्स + रीफ्रेश अंतराल 30 सेकंड diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 421b23f..4f8b3a8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -39,6 +39,7 @@ Avvisi glicemia + Avvisi attivi Avvisi glicemia Avvisi per glicemia alta e bassa con intervallo di ripetizione di 15 minuti Glicemia alta @@ -47,6 +48,21 @@ Glicemia in calo Previsto tra 15 min: %s + + Monitoraggio + Notifica di stato silenziosa mostrata mentre la glicemia viene monitorata in auto + Monitoraggio della glicemia + Gli avvisi restano attivi finché Android Auto è connesso + Monitoraggio della glicemia interrotto + Apri AutoSugar sullo schermo dell’auto per riprendere gli avvisi + + + Gli avvisi non possono essere recapitati + Le notifiche di AutoSugar sono disattivate, quindi gli avvisi glicemici non compariranno né in auto né sul telefono. + Apri le impostazioni di notifica + Gli avvisi richiedono l’autorizzazione a inviare notifiche + Impostazioni + Intervallo aggiornamento 30 sec diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 18f0743..62536f0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -39,6 +39,7 @@ 血糖アラート + アラート有効 血糖アラート 15分間隔の繰り返しで高血糖・低血糖アラート 高血糖 @@ -47,6 +48,21 @@ 血糖値下降傾向 15分後の予測: %s + + 監視 + 車内で血糖値を監視中に表示されるサイレントなステータス通知 + 血糖値を監視中 + Android Auto の接続中はアラートが有効です + 血糖値の監視を停止しました + アラートを再開するには車の画面で AutoSugar を開いてください + + + アラートを配信できません + AutoSugar の通知がオフのため、血糖値アラートは車内でもスマートフォンでも表示されません。 + 通知設定を開く + アラートには通知の権限が必要です + 設定 + 更新間隔 30秒 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index da94b6f..1322ac7 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -39,6 +39,7 @@ Glucosemeldingen + Meldingen aan Glucosemeldingen Meldingen voor hoge en lage bloedglucose met herhalings-cooldown van 15 minuten Hoge glucose @@ -47,6 +48,21 @@ Glucose daalt Voorspelling over 15 min: %s + + Bewaking + Stille statusmelding zolang de glucose in de auto wordt bewaakt + Glucose wordt bewaakt + Meldingen blijven actief zolang Android Auto verbonden is + Glucosebewaking gestopt + Open AutoSugar op het autoscherm om meldingen te hervatten + + + Meldingen kunnen niet worden bezorgd + Notificaties staan uit voor AutoSugar, dus glucosemeldingen verschijnen niet — niet in de auto en niet op je telefoon. + Notificatie-instellingen openen + Meldingen hebben toestemming voor notificaties nodig + Instellingen + Verversingsinterval 30 sec diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index fbd0017..52cc0de 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -39,6 +39,7 @@ Alertas de glicose + Alertas ativados Alertas de glicose Alertas de glicose alta e baixa com intervalo de repetição de 15 minutos Glicose alta @@ -47,6 +48,21 @@ Glicose tendendo a cair Previsto em 15 min: %s + + Monitorização + Notificação de estado silenciosa apresentada enquanto a glicose é monitorizada no carro + A monitorizar a glicose + Os alertas continuam ativos enquanto o Android Auto estiver ligado + Monitorização da glicose parada + Abre o AutoSugar no ecrã do carro para retomar os alertas + + + Os alertas não podem ser entregues + As notificações do AutoSugar estão desativadas, por isso os alertas de glicose não vão aparecer — nem no carro nem no telemóvel. + Abrir definições de notificações + Os alertas precisam de permissão para enviar notificações + Definições + Intervalo de atualização 30 seg diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 1d66e29..c150fe9 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -39,6 +39,7 @@ 血糖提醒 + 提醒已开启 血糖提醒 高低血糖提醒,15 分钟重复间隔 血糖偏高 @@ -47,6 +48,21 @@ 血糖呈下降趋势 预计 15 分钟后:%s + + 监测 + 在车内监测血糖期间显示的无声状态通知 + 正在监测血糖 + Android Auto 连接期间提醒保持启用 + 血糖监测已停止 + 请在车机屏幕上打开 AutoSugar 以恢复提醒 + + + 无法发送提醒 + AutoSugar 的通知已关闭,血糖提醒将不会在车机或手机上显示。 + 打开通知设置 + 提醒需要发送通知的权限 + 设置 + 刷新间隔 30 秒 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11d1190..0317c5a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,7 @@ Glucose alerts + Alerts on Glucose Alerts High and low blood glucose alerts with 15-minute repeat cooldown High glucose @@ -48,6 +49,21 @@ Glucose trending low Predicted in 15 min: %s + + Monitoring + Silent status notification shown while glucose is monitored in the car + Monitoring glucose + Alerts stay active while Android Auto is connected + Glucose monitoring stopped + Open AutoSugar on the car screen to resume alerts + + + Alerts can’t be delivered + Notifications are turned off for AutoSugar, so glucose alerts will not appear — neither in the car nor on your phone. + Open notification settings + Alerts need permission to post notifications + Settings + Refresh Interval 30 sec diff --git a/app/src/test/java/de/autosugar/car/GlucoseAlertManagerTest.kt b/app/src/test/java/de/autosugar/car/GlucoseAlertManagerTest.kt index 42452d9..6cbd50e 100644 --- a/app/src/test/java/de/autosugar/car/GlucoseAlertManagerTest.kt +++ b/app/src/test/java/de/autosugar/car/GlucoseAlertManagerTest.kt @@ -1,17 +1,23 @@ package de.autosugar.car +import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import androidx.core.app.NotificationManagerCompat import de.autosugar.R import de.autosugar.data.model.GlucoseUnit import io.mockk.every import io.mockk.justRun import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import io.mockk.slot import io.mockk.spyk import io.mockk.verify import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.After import org.junit.Before import org.junit.Test @@ -36,6 +42,8 @@ class GlucoseAlertManagerTest { 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 @@ -149,6 +157,86 @@ class GlucoseAlertManagerTest { // endregion + // region monitoring stopped + + @Test + fun `sendMonitoringStoppedAlert does not collide with any per-profile alert id`() { + 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) + manager.sendMonitoringStoppedAlert() + + assertEquals(5, idSlots.distinct().size) + } + + @Test + fun `sendMonitoringStoppedAlert carries no profile name`() { + val manager = buildManager() + manager.sendMonitoringStoppedAlert() + + verify { manager.post(any(), match { !it.contains("·") }, any()) } + } + + // endregion + + // region alertsDeliverable + + private fun mockNotificationsEnabled(enabled: Boolean) { + val compat = mockk(relaxed = true) + every { compat.areNotificationsEnabled() } returns enabled + mockkStatic(NotificationManagerCompat::class) + every { NotificationManagerCompat.from(mockContext) } returns compat + } + + @After + fun tearDown() { + unmockkStatic(NotificationManagerCompat::class) + } + + @Test + fun `alertsDeliverable is false when notifications are switched off for the app`() { + mockNotificationsEnabled(false) + + assertFalse(GlucoseAlertManager.alertsDeliverable(mockContext)) + } + + @Test + fun `alertsDeliverable is false when the alert channel itself is blocked`() { + mockNotificationsEnabled(true) + val channel = mockk() + every { channel.importance } returns NotificationManager.IMPORTANCE_NONE + every { mockNm.getNotificationChannel(any()) } returns channel + + assertFalse(GlucoseAlertManager.alertsDeliverable(mockContext)) + } + + @Test + fun `alertsDeliverable is true when the channel has not been created yet`() { + // The car app has never run on this install, so there is no channel to be blocked — + // that must not be reported as the user having turned alerts off. + mockNotificationsEnabled(true) + every { mockNm.getNotificationChannel(any()) } returns null + + assertTrue(GlucoseAlertManager.alertsDeliverable(mockContext)) + } + + @Test + fun `alertsDeliverable is true when the app and the channel are both enabled`() { + mockNotificationsEnabled(true) + val channel = mockk() + every { channel.importance } returns NotificationManager.IMPORTANCE_HIGH + every { mockNm.getNotificationChannel(any()) } returns channel + + assertTrue(GlucoseAlertManager.alertsDeliverable(mockContext)) + } + + // endregion + // region security exception @Test 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 8996052..fb6afd7 100644 --- a/app/src/test/java/de/autosugar/data/repository/NightscoutRepositoryTest.kt +++ b/app/src/test/java/de/autosugar/data/repository/NightscoutRepositoryTest.kt @@ -20,6 +20,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -386,6 +387,51 @@ class NightscoutRepositoryTest { assertFalse(result.first { it.id == "other-id" }.alertsEnabled) } + @Test + fun `setProfileEnabled toggles only the matching profile`() = runTest { + val other = profile.copy(id = "other-id") + val transformSlot = slot<(List) -> List>() + coEvery { mockDataStore.update(capture(transformSlot)) } just Runs + + repository.setProfileEnabled("test-id", false) + + val result = transformSlot.captured(listOf(profile, other)) + assertFalse(result.first { it.id == "test-id" }.enabled) + assertTrue(result.first { it.id == "other-id" }.enabled) + } + + @Test + fun `setProfileEnabled clears activeProfileId when the active profile is disabled`() = runTest { + // The car can no longer show it, so leaving it selected would strand the next session + // on a source that is not in its list. + coJustRun { mockDataStore.update(any()) } + repository.setActiveProfile("test-id") + + repository.setProfileEnabled("test-id", false) + + assertNull(repository.activeProfileId.value) + } + + @Test + fun `setProfileEnabled keeps activeProfileId when re-enabling`() = runTest { + coJustRun { mockDataStore.update(any()) } + repository.setActiveProfile("test-id") + + repository.setProfileEnabled("test-id", true) + + assertEquals("test-id", repository.activeProfileId.value) + } + + @Test + fun `enabledProfilesFlow hides disabled profiles from the car`() = runTest { + val disabled = profile.copy(id = "off-id", enabled = false) + every { mockDataStore.profilesFlow } returns flowOf(listOf(profile, disabled)) + val repo = NightscoutRepository(mockDataStore, mockFactory) + + assertEquals(listOf(profile), repo.enabledProfilesFlow.first()) + assertEquals(2, repo.profilesFlow.first().size) + } + // endregion // region hasElevatedPermissions diff --git a/app/src/test/java/de/autosugar/data/storage/ProfileSerializerTest.kt b/app/src/test/java/de/autosugar/data/storage/ProfileSerializerTest.kt index 04771b2..45fa824 100644 --- a/app/src/test/java/de/autosugar/data/storage/ProfileSerializerTest.kt +++ b/app/src/test/java/de/autosugar/data/storage/ProfileSerializerTest.kt @@ -4,6 +4,7 @@ import de.autosugar.data.model.GlucoseUnit import de.autosugar.data.model.NightscoutProfile import de.autosugar.data.model.ProfileIcon import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -35,6 +36,24 @@ class ProfileSerializerTest { assertTrue(result[0].alertsEnabled) } + @Test + fun `round-trip preserves a disabled profile`() { + val disabled = profile.copy(enabled = false) + val result = serializer.fromJson(serializer.toJson(listOf(disabled))) + assertFalse(result[0].enabled) + } + + @Test + fun `profiles stored before the enabled field default to enabled`() { + // Anything written by 1.2.6 or earlier has no "enabled" key; those sources must stay + // visible in the car after the update rather than silently disappearing. + val json = """[{"id":"x","displayName":"X","baseUrl":"http://x.test","apiToken":"","unit":"MG_DL","icon":"PERSON","alertsEnabled":true}]""" + val result = serializer.fromJson(json) + assertEquals(1, result.size) + assertTrue(result[0].enabled) + assertTrue(result[0].alertsEnabled) + } + @Test fun `round-trip preserves mmol_L unit`() { val mmol = profile.copy(unit = GlucoseUnit.MMOL_L) diff --git a/app/src/test/java/de/autosugar/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/autosugar/ui/settings/SettingsViewModelTest.kt index 62293e2..0e20301 100644 --- a/app/src/test/java/de/autosugar/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/autosugar/ui/settings/SettingsViewModelTest.kt @@ -70,17 +70,31 @@ class SettingsViewModelTest { } @Test - fun `setAlertsEnabled delegates to repository atomic toggle`() = runTest { + fun `setProfileEnabled delegates to repository atomic toggle`() = runTest { val idSlot = slot() val enabledSlot = slot() - coJustRun { mockRepository.setAlertsEnabled(capture(idSlot), capture(enabledSlot)) } + coJustRun { mockRepository.setProfileEnabled(capture(idSlot), capture(enabledSlot)) } - viewModel.setAlertsEnabled("id-1", enabled = true) + viewModel.setProfileEnabled("id-1", enabled = false) advanceUntilIdle() assertEquals("id-1", idSlot.captured) - assertEquals(true, enabledSlot.captured) - coVerify(exactly = 1) { mockRepository.setAlertsEnabled("id-1", true) } + assertEquals(false, enabledSlot.captured) + coVerify(exactly = 1) { mockRepository.setProfileEnabled("id-1", false) } + } + + @Test + fun `profiles StateFlow keeps disabled profiles so they stay editable`() = runTest { + // The car reads enabledProfilesFlow; the settings list must still show a source the + // user switched off, or there would be no way to switch it back on. + every { mockRepository.profilesFlow } returns + flowOf(listOf(profile1.copy(enabled = false), profile2)) + val vm = SettingsViewModel(mockRepository, mockAppPrefs) + backgroundScope.launch { vm.profiles.collect {} } + advanceUntilIdle() + + assertEquals(2, vm.profiles.value.size) + assertEquals(false, vm.profiles.value[0].enabled) } @Test diff --git a/docs/adr/0003-alert-monitoring-outlives-the-car-session.md b/docs/adr/0003-alert-monitoring-outlives-the-car-session.md new file mode 100644 index 0000000..78ed74a --- /dev/null +++ b/docs/adr/0003-alert-monitoring-outlives-the-car-session.md @@ -0,0 +1,61 @@ +# ADR 003: Alert Monitoring Outlives the Car Session + +## Status +Accepted + +## Context +Glucose alerts only ever appeared while AutoSugar was the app currently on the car screen — which +is precisely when they are useless, because the driver can already see the reading. The moment the +user switched to Maps, alerting went silent, and that is the case the feature exists for. + +The cause was lifecycle ownership. Since 1.2.5 the alert loop has run on `AutoSugarSession`'s +`lifecycleScope` (ADR 001 chose the IoT category partly to "stay active in the background", but the +`Session` is not the thing that stays active). A `Session`'s lifecycle tracks *car-screen +visibility*: the host stops it when another car app takes the screen and destroys it thereafter, and +`lifecycleScope` is cancelled at `ON_DESTROY`. With no started component of its own, the app's +process is then a cached background process — no polling, no network, no alerts. + +Android's documented mechanism for work that must continue while the app is not visible is a started +foreground service. The remaining question was which foreground service type, since Android 14 +gates each type behind prerequisites: + +* **`connectedDevice`** describes the situation best ("interacting with an external device such as + … a car") but requires the app to hold one of `CHANGE_NETWORK_STATE`, `CHANGE_WIFI_STATE`, + `CHANGE_WIFI_MULTICAST_STATE`, `NFC`, `TRANSMIT_IR`, a Bluetooth/UWB runtime permission, or a USB + grant. AutoSugar needs none of those and declaring one purely to pass the gate would be a false + permission declaration on the Play listing. +* **`specialUse`** requires a `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` declaration and a Play review + justification — extra review friction on an app whose reviews are already slow. +* **`dataSync`** has no permission prerequisites and describes exactly what the loop does: fetch + readings from Nightscout on a timer. Its cost is the Android 15+ budget of six hours per 24-hour + window, after which the system calls `Service.onTimeout()` and kills the service if it does not + stop itself. + +## Decision +1. **Polling moves into `GlucoseMonitorService`**, a started foreground service of type `dataSync`. + `BackgroundAlertMonitor` is unchanged apart from taking a plain `Context`. +2. **Its lifetime is the car *connection*, not the car *screen*.** The service observes + `CarConnection.getType()` and stops itself on `CONNECTION_TYPE_NOT_CONNECTED`. The car app library + already declares the `` entry that provider lookup needs, and a missing or unreadable + provider reports "not connected", so the service can never outlive a drive. +3. **`AutoSugarSession` only starts it.** The session stays the trigger — the host creating a session + is the app's signal that Android Auto is up — but owns none of the polling. Repeat starts are + idempotent. +4. **A refused start falls back to the old in-session loop.** Android 12+ can reject a + foreground-service start it judges to come from the background; rather than lose alerting + outright, the session then polls on its own scope exactly as before. +5. **The six-hour cap fails loudly.** `onTimeout()` posts a "glucose monitoring stopped" alert + through `CarNotificationManager` — so it reaches the car screen — before calling `stopSelf()`. +6. **The ongoing service notification is silent** (`IMPORTANCE_LOW`, no badge) and is not extended + for the car, so it stays a phone-side status indicator and never competes with an actual alert. + +## Consequences +* **Pros:** Alerts now fire in the situation they were written for — AutoSugar in the background, + Android Auto connected. Alert cooldowns and per-profile state live in one place for a whole drive + instead of being reset every time the session is rebuilt. +* **Cons:** A persistent notification appears on the phone for the duration of a drive. Continuous + monitoring is capped at six hours per 24 hours by the platform; beyond that the driver is told + monitoring stopped and has to reopen AutoSugar on the car screen to restart it. +* **Follow-up:** `POST_NOTIFICATIONS` is declared but never requested at runtime, so on Android 13+ + every alert is silently swallowed until the user grants notifications by hand. That gap is + orthogonal to this ADR but blocks the same feature. diff --git a/docs/whatsnew/1.2.7-ar b/docs/whatsnew/1.2.7-ar new file mode 100644 index 0000000..fa2d940 --- /dev/null +++ b/docs/whatsnew/1.2.7-ar @@ -0,0 +1,3 @@ +تصلك الآن تنبيهات الجلوكوز حتى عندما يكون تطبيق آخر على شاشة السيارة. كانت سابقاً تظهر فقط أمام AutoSugar، أي حين لا تحتاجها. + +ويمكن أيضاً إيقاف أي مصدر لكي لا يظهر في السيارة، وتشير أيقونة جرس إلى المصادر المفعّلة تنبيهاتها. diff --git a/docs/whatsnew/1.2.7-de-DE b/docs/whatsnew/1.2.7-de-DE new file mode 100644 index 0000000..a98c778 --- /dev/null +++ b/docs/whatsnew/1.2.7-de-DE @@ -0,0 +1,3 @@ +Glukose-Alarme erreichen dich jetzt auch, wenn eine andere App auf dem Autobildschirm läuft. Bisher erschienen sie nur, solange AutoSugar selbst zu sehen war — also genau dann, wenn man sie nicht brauchte. + +Quellen lassen sich einzeln abschalten, damit sie im Auto nicht auftauchen; eine Glocke zeigt aktive Alarme. diff --git a/docs/whatsnew/1.2.7-en-US b/docs/whatsnew/1.2.7-en-US new file mode 100644 index 0000000..8edb068 --- /dev/null +++ b/docs/whatsnew/1.2.7-en-US @@ -0,0 +1,3 @@ +Glucose alerts now reach you while another app is on the car screen. Until now they only appeared while AutoSugar itself was in front of you — which is exactly when you didn't need them. + +Sources can also be switched off individually so they stay out of the car, and a bell marks the ones with alerts turned on. diff --git a/docs/whatsnew/1.2.7-es-ES b/docs/whatsnew/1.2.7-es-ES new file mode 100644 index 0000000..b714ca5 --- /dev/null +++ b/docs/whatsnew/1.2.7-es-ES @@ -0,0 +1,3 @@ +Las alertas de glucosa ahora te llegan aunque otra app esté en la pantalla del coche. Hasta ahora solo aparecían mientras veías AutoSugar, justo cuando no las necesitabas. + +También puedes desactivar fuentes concretas para que no salgan en el coche, y una campana marca las que tienen alertas activadas. diff --git a/docs/whatsnew/1.2.7-fr-FR b/docs/whatsnew/1.2.7-fr-FR new file mode 100644 index 0000000..11c267c --- /dev/null +++ b/docs/whatsnew/1.2.7-fr-FR @@ -0,0 +1,3 @@ +Les alertes de glucose vous parviennent désormais même quand une autre appli occupe l’écran de la voiture. Jusqu’ici, elles n’apparaissaient que si AutoSugar était affiché — précisément quand elles étaient inutiles. + +Chaque source peut aussi être désactivée ; une cloche signale celles dont les alertes sont actives. diff --git a/docs/whatsnew/1.2.7-hi-IN b/docs/whatsnew/1.2.7-hi-IN new file mode 100644 index 0000000..9fdbe5c --- /dev/null +++ b/docs/whatsnew/1.2.7-hi-IN @@ -0,0 +1,3 @@ +ग्लूकोज़ अलर्ट अब तब भी मिलते हैं जब कार स्क्रीन पर कोई दूसरा ऐप हो। पहले वे सिर्फ़ AutoSugar दिखने पर आते थे — यानी जब ज़रूरत नहीं थी। + +स्रोतों को अलग-अलग बंद भी कर सकते हैं, और घंटी का निशान दिखाता है कि किनके अलर्ट चालू हैं। diff --git a/docs/whatsnew/1.2.7-it-IT b/docs/whatsnew/1.2.7-it-IT new file mode 100644 index 0000000..693787a --- /dev/null +++ b/docs/whatsnew/1.2.7-it-IT @@ -0,0 +1,3 @@ +Gli avvisi glicemici ora ti raggiungono anche quando un’altra app occupa lo schermo dell’auto. Finora comparivano solo mentre vedevi AutoSugar, cioè quando non servivano. + +Ogni fonte può anche essere disattivata singolarmente per non comparire in auto, e una campanella indica quelle con gli avvisi attivi. diff --git a/docs/whatsnew/1.2.7-ja-JP b/docs/whatsnew/1.2.7-ja-JP new file mode 100644 index 0000000..2a2e464 --- /dev/null +++ b/docs/whatsnew/1.2.7-ja-JP @@ -0,0 +1,3 @@ +血糖値アラートが、他のアプリを車の画面に表示している間も届くようになりました。これまでは AutoSugar を表示中のときだけ、つまり不要なときだけ表示されていました。 + +ソースを個別にオフにして車内に表示しないこともでき、アラート有効のソースにはベルマークが付きます。 diff --git a/docs/whatsnew/1.2.7-nl-NL b/docs/whatsnew/1.2.7-nl-NL new file mode 100644 index 0000000..f584a75 --- /dev/null +++ b/docs/whatsnew/1.2.7-nl-NL @@ -0,0 +1,3 @@ +Glucosemeldingen bereiken je nu ook als een andere app op het autoscherm staat. Tot nu toe verschenen ze alleen terwijl je AutoSugar zelf zag — precies wanneer je ze niet nodig had. + +Bronnen kun je ook afzonderlijk uitzetten zodat ze niet in de auto verschijnen; een belletje markeert de bronnen met meldingen aan. diff --git a/docs/whatsnew/1.2.7-pt-PT b/docs/whatsnew/1.2.7-pt-PT new file mode 100644 index 0000000..0f3cf08 --- /dev/null +++ b/docs/whatsnew/1.2.7-pt-PT @@ -0,0 +1,3 @@ +Os alertas de glicose passam a chegar-te mesmo quando outra app está no ecrã do carro. Até agora apareciam só enquanto vias o AutoSugar, ou seja, quando não eram precisos. + +Cada fonte pode também ser desativada para não aparecer no carro, e uma campainha marca as que têm alertas ativados. diff --git a/docs/whatsnew/1.2.7-zh-CN b/docs/whatsnew/1.2.7-zh-CN new file mode 100644 index 0000000..f8f2b3f --- /dev/null +++ b/docs/whatsnew/1.2.7-zh-CN @@ -0,0 +1,3 @@ +血糖提醒现在在车机屏幕上运行其他应用时也能送达。以前它们只在 AutoSugar 显示时才出现,而那正是你不需要它们的时候。 + +现在还可以单独关闭某个数据源,让它不在车机上显示;已开启提醒的数据源会标有一个铃铛图标。 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef39cb7..1cb22dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ datastore-preferences = { module = "androidx.datastore:datastore-preferenc lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }