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..e236dde 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,30 @@ 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) { + 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) + } + "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)