From d68a35f00dd87d45ce461337b69a1b4c34275b4a Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:10:40 +0100 Subject: [PATCH 1/2] Stop ringing when a dose is marked on Today; warn when full-screen alarms are off - Marking a ringing dose taken/skipped from Today disarmed it but the alarm service kept ringing. cancelFallback now also sends ACTION_STOP for that dose. - Android 14+ can switch off full-screen intents (special access). Then the alarm rings but never takes over the lockscreen. Today now shows a warning card with a Fix button, like the notifications one. Found by the read-only alarm bug hunt. Co-Authored-By: Claude Opus 5.5 --- .../dev/susiee/dosekeeper/AlarmService.kt | 9 +++++++- .../dev/susiee/dosekeeper/MainActivity.kt | 21 +++++++++++++++++++ lib/services/alarm_bridge.dart | 7 +++++++ lib/ui/today_page.dart | 14 +++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt index f6bc3f5..f81d8bb 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt @@ -251,8 +251,15 @@ class AlarmService : Service() { nm.notify(NOTIFICATION_ID, n) } - /** The dose was dealt with elsewhere (Today screen): stop its fallback notification ringing. */ + /** The dose was dealt with elsewhere (Today screen): stop it ringing, service or fallback. */ fun cancelFallback(context: Context, doseId: Long) { + // Marked taken on Today while the service rings for it: without this it kept ringing. + if (doseId in ringing) { + context.startService(Intent(context, AlarmService::class.java).apply { + action = ACTION_STOP + putExtra(EXTRA_DOSE_IDS, longArrayOf(doseId)) + }) + } val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // One id for every dose, so check it's this dose's before clearing it. if (nm.activeNotifications.any { diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt index f434367..1fa8504 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt @@ -118,6 +118,27 @@ class MainActivity : FlutterActivity() { * lockscreen takeover never happens. Below 33 the permission does not exist * and notifications are allowed by default. */ + /** + * Android 14+ made full-screen intents a special access the user (or the + * OS, after a reinstall) can switch off. Then the alarm rings but never + * takes over the lockscreen -- same silent failure as notifications off. + */ + "canUseFullScreenIntent" -> result.success( + Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || + (getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager) + .canUseFullScreenIntent(), + ) + + "openFullScreenIntentSettings" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivity( + Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT) + .setData(Uri.parse("package:$packageName")), + ) + } + result.success(true) + } + "hasNotificationPermission" -> { val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission( diff --git a/lib/services/alarm_bridge.dart b/lib/services/alarm_bridge.dart index f5c11f9..42e0035 100644 --- a/lib/services/alarm_bridge.dart +++ b/lib/services/alarm_bridge.dart @@ -51,6 +51,13 @@ class AlarmBridge { static Future requestNotificationPermission() => _channel.invokeMethod('requestNotificationPermission'); + /// Android 14+: the lockscreen takeover also needs this switch on. Always true below 14. + static Future canUseFullScreenIntent() async => + await _channel.invokeMethod('canUseFullScreenIntent') ?? true; + + static Future openFullScreenIntentSettings() => + _channel.invokeMethod('openFullScreenIntentSettings'); + /// Notification for doses past their grace period, as of [shownAt]. Stays until /// [clearOutstandingNotification] is called or the user swipes it away, which /// discards those doses for good: see [outstandingDismissedThrough]. diff --git a/lib/ui/today_page.dart b/lib/ui/today_page.dart index 39b7937..ef9fefb 100644 --- a/lib/ui/today_page.dart +++ b/lib/ui/today_page.dart @@ -25,6 +25,7 @@ class TodayPageState extends State { int _unarmed = 0; bool _batteryOk = true; bool _notificationsOk = true; + bool _fullScreenOk = true; // Battery *level*, distinct from _batteryOk (the optimisation whitelist). bool _batteryLevelOk = true; bool _backupsOn = true; @@ -50,6 +51,7 @@ class TodayPageState extends State { final exact = await AlarmBridge.canScheduleExact(); final battery = await AlarmBridge.isIgnoringBatteryOptimizations(); final notifications = await AlarmBridge.hasNotificationPermission(); + final fullScreen = await AlarmBridge.canUseFullScreenIntent(); final batteryLow = await AlarmBridge.isBatteryLow(); // After sync, so the backup includes anything just drained from the alarm screen. final backup = await BackupService.instance.backUpQuietly(); @@ -69,6 +71,7 @@ class TodayPageState extends State { _unarmed = widget.scheduler.unarmedAlarms; _batteryOk = battery; _notificationsOk = notifications; + _fullScreenOk = fullScreen; _batteryLevelOk = !batteryLow; // A failed status read counts as "on": the failure itself is shown instead. _backupsOn = backup?.enabled ?? true; @@ -111,6 +114,17 @@ class TodayPageState extends State { await refresh(); }, ), + if (_notificationsOk && !_fullScreenOk) + _Warning( + text: 'Full-screen alarms are off. The alarm will ring but won\'t take ' + 'over the lockscreen.', + actionLabel: 'Fix', + onAction: () async { + await AlarmBridge.openFullScreenIntentSettings(); + await Future.delayed(const Duration(seconds: 1)); + await refresh(); + }, + ), // A refused alarm never rings, and nothing else would say so until the dose is // already missed. if (_unarmed > 0) From bada70e18cefbe4ba3617d8287e92d87deae0282 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:15:21 +0100 Subject: [PATCH 2/2] Fall back to the app's settings page when the full-screen-intent screen is missing Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/dev/susiee/dosekeeper/MainActivity.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt index 1fa8504..e236dde 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt @@ -131,10 +131,13 @@ class MainActivity : FlutterActivity() { "openFullScreenIntentSettings" -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startActivity( - Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT) - .setData(Uri.parse("package:$packageName")), - ) + val pkg = Uri.parse("package:$packageName") + try { + startActivity(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).setData(pkg)) + } catch (e: ActivityNotFoundException) { + // OEM builds may drop that screen; the app's own page still has the switch. + startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).setData(pkg)) + } } result.success(true) }