From 8354be7a3642d69d4dca9e992ed876dab842f11c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:46:21 +0000 Subject: [PATCH] Ring the Alarm style from a foreground service; roll a passed time forward Alarm-style memos arrived as a silent heads-up on an unlocked phone in vibrate mode, with every permission granted. The #52 fix started AlarmActivity from AlarmReceiver, but an alarm receiver gets no background-activity-launch exemption: the start only went through inside the few seconds after the app was on screen, which is exactly when the Settings test ring fired, so the test passed while real alarms were blocked (silently: a blocked startActivity does not throw). - AlarmRingService: a foreground service (type systemExempted) started from AlarmReceiver via NotificationHelper.deliverOnTime. It posts the alert with startForeground and loops AlarmRinger on the alarm stream, locked or not, until answered, swiped, or the 2 minute timeout. - AlarmActivity is now the answer screen; it rings itself only when the service is not ringing (late BootWorker deliveries, refused starts). - Every answer path silences by the same id (AlertIds): notification Done/Snooze, swipe (delete intent), the ring screen, the in-app nudge, and opening the app from the alert. - Manifest: FOREGROUND_SERVICE and FOREGROUND_SERVICE_SYSTEM_EXEMPTED, documented; CLAUDE.md permission list updated. - The test ring now fires after a minute so it runs in the conditions a real alarm does. - Edit sheet: changing the time of a once-only memo to one already gone rolls it to the next occurrence (ringForPickedTime), and a ring moved into the past cannot be saved (onceOnlyRingSavable). LESSONS #65 records the background-start trap; #52 points to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012c2DoXy9u68tYVUnmziZPy --- .claude/CLAUDE.md | 4 +- LESSONS.md | 42 +++++ app/src/main/AndroidManifest.xml | 12 ++ .../main/java/com/mapgie/dash/MainActivity.kt | 11 +- .../mapgie/dash/alarm/AlarmActionReceiver.kt | 27 ++- .../com/mapgie/dash/alarm/AlarmActivity.kt | 46 +++-- .../com/mapgie/dash/alarm/AlarmReceiver.kt | 17 +- .../com/mapgie/dash/alarm/AlarmRingService.kt | 159 ++++++++++++++++++ .../com/mapgie/dash/alarm/AlarmScheduler.kt | 9 +- .../com/mapgie/dash/data/model/Reminder.kt | 22 +++ .../mapgie/dash/notification/DeliveryStyle.kt | 19 ++- .../dash/notification/NotificationHelper.kt | 100 +++++++---- .../dash/ui/components/AddReminderSheet.kt | 10 +- .../screens/reminder/ReminderViewViewModel.kt | 11 +- .../ui/screens/settings/SettingsScreen.kt | 10 +- .../ui/screens/settings/SettingsViewModel.kt | 2 +- .../dash/data/model/ReminderPickedTimeTest.kt | 77 +++++++++ .../dash/notification/DeliveryStyleTest.kt | 18 +- changelog/unreleased/alarm-ring-service.json | 11 ++ 19 files changed, 521 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/com/mapgie/dash/alarm/AlarmRingService.kt create mode 100644 app/src/test/java/com/mapgie/dash/data/model/ReminderPickedTimeTest.kt create mode 100644 changelog/unreleased/alarm-ring-service.json diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 62fbd569..8f195087 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -102,7 +102,7 @@ reading code. | When a memo or tag-alarm rings, arms, advances | `data/model/Reminder.kt` (memo lifecycle) and `data/model/TagAlarm.kt` (tag-alarm rules); `ReminderSchedule.kt` for the words | `ReminderModelTest`, `TagAlarmModelTest`, `ReminderScheduleTest` | | Arming / turning off a tag-alarm from anywhere | `tagalarm/TagAlarmService.kt` (the only entry point) | Callers: `MainActivity`, `RemindersListViewModel`, `ReminderViewViewModel` | | What happens on an NFC tap | `MainActivity.handleNfcIntent` → `routeScannedTag` (tag-alarm first, then chore paths) | `nfc/NfcHandler.kt` for reading, writing, erasing | -| Scheduling, ringing, boot, snooze | `alarm/AlarmScheduler.kt` (`syncReminder` after every mutation), `AlarmReceiver`, `AlarmActionReceiver`, `BootWorker`, `AlarmActivity` + `AlarmRinger` | `notification/NotificationHelper.kt` for channels and the full-screen intent | +| Scheduling, ringing, boot, snooze | `alarm/AlarmScheduler.kt` (`syncReminder` after every mutation), `AlarmReceiver`, `AlarmActionReceiver`, `BootWorker`, `AlarmRingService` (owns the ring) + `AlarmRinger`, `AlarmActivity` (the ring screen) | `notification/NotificationHelper.kt` for channels and the full-screen intent | | A Settings page | `ui/screens/settings/SettingsScreen.kt` (the `SettingsSubScreen` enum and dispatch) + one `SubScreen.kt`; controls in `CozyControls.kt` | Its own `ViewModel.kt` if it has state worth testing (`TagsViewModel` is the pattern) | | Supabase reads/writes | `data/repository/ChoreRepository.kt`, `TaskRepository.kt` | `supabase/schema.sql` for tables, RLS, grants | | Private (phone-only) chores and tasks | `data/model/PrivateItems.kt` (the document and `privateMove`), `data/preferences/PrivateItemStore.kt` | The routing in both repositories; `PrivateItemsTest` | @@ -150,7 +150,7 @@ Facts that save a detour: - **Theme:** Five built-in Material 3 palettes (Cream default, implementing the "Cozy Cream" design system; Mist, Sage, Coral, Teal in `ui/theme/Color.kt`) plus a custom theme with per-role colour pickers and background overrides; light/dark/system brightness and a WCAG high-contrast toggle (`DashTheme` in `ui/theme/Theme.kt`). Headers use Lora (serif), body/UI text uses Nunito (`ui/theme/Type.kt`); shared shape/spacing tokens live in `ui/theme/Shape.kt` and `ui/theme/Dimens.kt`, and the fixed status tones (rose/amber/sage) in `ui/theme/Color.kt` + `ui/theme/StatusTone.kt`. - **Background work:** WorkManager (`BootWorker`, `DailyStaleChoreWorker`) + AlarmManager (`AlarmScheduler`, `AlarmReceiver`, `AlarmActivity` for the full-screen ring) for task reminders and memos, scheduled via Hilt-injected workers. A tag-alarm's morning (first ring plus follow-ups) advances through the memo's `remindAt` under one alarm identity. - **NFC:** `MainActivity` handles NFC foreground dispatch and routes a scanned id: a tag-alarm's tag arms it (`TagAlarmService`), anything else goes to the chore paths. `NfcHandler` reads ids (text record, `chordash://tag?tag=` or `chordash://memo?memo=` URI, hardware UID), writes them, and erases stickers. Settings › NFC tags (`TagsSubScreen`) is the maintenance page. -- **Permissions:** `NFC`, `SCHEDULE_EXACT_ALARM`, `USE_EXACT_ALARM`, `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`, `INTERNET` (required for Supabase), `ACCESS_NOTIFICATION_POLICY` (lets the app appear in Settings > Do Not Disturb access and lets reminder alarms bypass Do Not Disturb). Do not add new permissions without discussion, and document the reason for each one in the manifest. +- **Permissions:** `NFC`, `SCHEDULE_EXACT_ALARM`, `USE_EXACT_ALARM`, `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, `VIBRATE`, `INTERNET` (required for Supabase), `ACCESS_NOTIFICATION_POLICY` (lets the app appear in Settings > Do Not Disturb access and lets reminder alarms bypass Do Not Disturb), `USE_FULL_SCREEN_INTENT` (the Alarm style's ring screen over the lock screen), `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` (`AlarmRingService`, which rings the Alarm style on the alarm stream). Do not add new permissions without discussion, and document the reason for each one in the manifest. ## Key Rules diff --git a/LESSONS.md b/LESSONS.md index feedbcec..afc83c11 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -1156,6 +1156,10 @@ on an unlocked phone. Two facts combine into the bug: heads-up instead, so `AlarmRinger` never ran and the only sound was the notification on the (muted) notification stream. +> **Superseded by #65.** The direct activity start below only worked inside a few +> seconds of the app being on screen, which is why the Settings test ring passed +> while real alarms stayed silent. The diagnosis in this lesson stands; the fix is #65. + Fix: when a real-time alarm fires, `AlarmReceiver` starts `AlarmActivity` itself (`NotificationHelper.startAlarmRingScreen`) for the Alarm style, so the alarm-stream ring plays whether the phone is locked or not. This is allowed because an app that @@ -1465,3 +1469,41 @@ or the row already has one (`chorePatch`), because PostgREST rejects a body that names a column the live database has not had added yet; and `interval_days` keeps the approximate length (a year is 365) next to `repeat_unit`, so anything that reads only the old column still behaves. + +## 65. An alarm receiver cannot start an activity from the background: ring from a foreground service + +#52's fix had `AlarmReceiver` call `startActivity(AlarmActivity)` on the belief +that an exact-alarm broadcast grants a background-activity-launch window. It +does not; that exemption is not on Android's list. What made the fix look right +was the Settings test ring: it fired 10 seconds after the tap, inside the short +grace period Android gives an app that was just on screen, so the launch went +through every time. A real memo fires long after the app was put away, the launch +is blocked, and a blocked `startActivity` does not throw (logcat says +"Background activity launch blocked"), so the `runCatching` around it caught +nothing. The user saw a heads-up notification on the muted notification stream, +Samsung in vibrate mode, with every permission row green. + +The documented exemption for alarms is a **foreground service** start: an app +whose exact alarm just fired may start one. So the ring now lives in +`AlarmRingService` (`foregroundServiceType="systemExempted"`, which Android 14+ +reserves for exact-alarm holders keeping an alarm ringing). `AlarmReceiver` +builds the notification and hands it to `NotificationHelper.deliverOnTime`, +which starts the service for the Alarm style; the service posts it with +`startForeground` and loops `AlarmRinger` on the alarm stream, locked or not. +`AlarmActivity` is only the answer screen now: it rings itself only when the +service is not ringing (a late BootWorker delivery), and leaving it ends the ring. + +Rules that fall out: +- **A test must fire in the conditions the bug needs.** A self-test that runs + seconds after a tap inherits "the app is in use" and cannot see a + background-start bug. The test ring now waits a minute and asks the user to + leave the app first. +- **Every path that answers an alert silences the ring by the same id** + (`AlertIds`): the notification's Done / Snooze, the swipe (`setDeleteIntent`), + the ring screen, the in-app nudge, opening the app from the alert. A + foreground service's notification ignores `cancel()`, so the service removes + it (`STOP_FOREGROUND_REMOVE`) or leaves it behind for later (`DETACH`, on a + timeout or when the screen closes). +- **Fall back, don't fail.** If Android refuses the service start, the alert is + posted the old way; the full-screen intent still opens the ring screen on a + locked phone and that screen rings itself. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 68026b7b..0285fb11 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,15 @@ the user can revoke this per app; Settings > Reminders & alerts shows the state and deep-links to the toggle. --> + + + + + + diff --git a/app/src/main/java/com/mapgie/dash/MainActivity.kt b/app/src/main/java/com/mapgie/dash/MainActivity.kt index eda677a3..82ffa25b 100644 --- a/app/src/main/java/com/mapgie/dash/MainActivity.kt +++ b/app/src/main/java/com/mapgie/dash/MainActivity.kt @@ -34,6 +34,7 @@ import com.mapgie.dash.data.repository.ChoreRepository import com.mapgie.dash.nfc.NfcHandler import com.mapgie.dash.nfc.NfcWriteRequest import com.mapgie.dash.nfc.NfcWriteResult +import com.mapgie.dash.alarm.AlarmRingService import com.mapgie.dash.notification.NotificationHelper import com.mapgie.dash.tagalarm.TagAlarmService import com.mapgie.dash.ui.navigation.DashNavGraph @@ -213,11 +214,15 @@ class MainActivity : ComponentActivity() { if (intent == null) return val reminderId = intent.getStringExtra(NotificationHelper.EXTRA_REMINDER_ID) val taskId = intent.getStringExtra(NotificationHelper.EXTRA_TASK_ID) - pendingReminderView = when { - !reminderId.isNullOrBlank() -> ReminderViewKind.REMINDER.routeArg to reminderId - !taskId.isNullOrBlank() -> ReminderViewKind.TASK.routeArg to taskId + val (kind, id) = when { + !reminderId.isNullOrBlank() -> ReminderViewKind.REMINDER to reminderId + !taskId.isNullOrBlank() -> ReminderViewKind.TASK to taskId else -> return } + // Opening a ringing alert is answering it: the ring stops, the alert stays + // in the shade until Done or Snooze. + AlarmRingService.silence(NotificationHelper.notifyId(kind, id), keepNotification = true) + pendingReminderView = kind.routeArg to id } private fun handleNfcIntent(intent: Intent, fromForeground: Boolean) { diff --git a/app/src/main/java/com/mapgie/dash/alarm/AlarmActionReceiver.kt b/app/src/main/java/com/mapgie/dash/alarm/AlarmActionReceiver.kt index 02ea2128..6a99541f 100644 --- a/app/src/main/java/com/mapgie/dash/alarm/AlarmActionReceiver.kt +++ b/app/src/main/java/com/mapgie/dash/alarm/AlarmActionReceiver.kt @@ -6,6 +6,7 @@ import android.content.Intent import androidx.core.app.NotificationManagerCompat import com.mapgie.dash.data.repository.ReminderRepository import com.mapgie.dash.data.repository.TaskRepository +import com.mapgie.dash.notification.AlertIds import com.mapgie.dash.notification.NotificationHelper import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope @@ -29,7 +30,7 @@ class AlarmActionReceiver : BroadcastReceiver() { "com.mapgie.dash.ACTION_SNOOZE_TASK" -> { val taskId = intent.getStringExtra(NotificationHelper.EXTRA_TASK_ID) ?: return val taskTitle = intent.getStringExtra(NotificationHelper.EXTRA_TASK_TITLE) ?: "Task" - nm.cancel(taskId.hashCode()) + silenceAndClear(nm, AlertIds.task(taskId)) alarmScheduler.scheduleTask( taskId, taskTitle, @@ -39,7 +40,7 @@ class AlarmActionReceiver : BroadcastReceiver() { "com.mapgie.dash.ACTION_DONE_TASK" -> { val taskId = intent.getStringExtra(NotificationHelper.EXTRA_TASK_ID) ?: return - nm.cancel(taskId.hashCode()) + silenceAndClear(nm, AlertIds.task(taskId)) val result = goAsync() CoroutineScope(Dispatchers.IO).launch { try { @@ -58,7 +59,7 @@ class AlarmActionReceiver : BroadcastReceiver() { // Preserve the task link across the snooze, otherwise the re-fired // reminder never marks its linked task as reminded. val taskId = intent.getStringExtra(NotificationHelper.EXTRA_TASK_ID) - nm.cancel(("reminder_$reminderId").hashCode()) + silenceAndClear(nm, AlertIds.reminder(reminderId)) alarmScheduler.scheduleReminder( reminderId, subject, @@ -69,7 +70,7 @@ class AlarmActionReceiver : BroadcastReceiver() { "com.mapgie.dash.ACTION_DONE_REMINDER" -> { val reminderId = intent.getStringExtra(NotificationHelper.EXTRA_REMINDER_ID) ?: return - nm.cancel(("reminder_$reminderId").hashCode()) + silenceAndClear(nm, AlertIds.reminder(reminderId)) val result = goAsync() CoroutineScope(Dispatchers.IO).launch { try { @@ -82,6 +83,24 @@ class AlarmActionReceiver : BroadcastReceiver() { } } } + + // The user swiped away a ringing Alarm-style alert: the ring goes with it. + ACTION_RING_DISMISSED -> { + val notifyId = intent.getIntExtra(EXTRA_NOTIFY_ID, 0) + AlarmRingService.silence(notifyId, keepNotification = false) + } } } + + // An answered alert stops ringing and leaves the shade. The ring service owns a + // ringing alert's notification, so it removes it; cancel covers every other case. + private fun silenceAndClear(nm: NotificationManagerCompat, notifyId: Int) { + AlarmRingService.silence(notifyId, keepNotification = false) + nm.cancel(notifyId) + } + + companion object { + const val ACTION_RING_DISMISSED = "com.mapgie.dash.ACTION_RING_DISMISSED" + const val EXTRA_NOTIFY_ID = "notify_id" + } } diff --git a/app/src/main/java/com/mapgie/dash/alarm/AlarmActivity.kt b/app/src/main/java/com/mapgie/dash/alarm/AlarmActivity.kt index 910f7414..9b9559fb 100644 --- a/app/src/main/java/com/mapgie/dash/alarm/AlarmActivity.kt +++ b/app/src/main/java/com/mapgie/dash/alarm/AlarmActivity.kt @@ -10,21 +10,28 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.lifecycleScope +import com.mapgie.dash.notification.NotificationHelper +import com.mapgie.dash.ui.screens.reminder.REMINDER_VIEW_ARG_ID +import com.mapgie.dash.ui.screens.reminder.REMINDER_VIEW_ARG_KIND import com.mapgie.dash.ui.screens.reminder.REMINDER_VIEW_ARG_SOUND +import com.mapgie.dash.ui.screens.reminder.ReminderViewKind import com.mapgie.dash.ui.screens.reminder.ReminderViewScreen import com.mapgie.dash.ui.theme.DashTheme import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.minutes /** * The screen the Alarm delivery mode throws up when a reminder fires: launched * by the notification's full-screen intent, so on a locked or sleeping phone it - * turns the screen on and shows over the lock screen like a clock alarm, and - * rings and vibrates (see [AlarmRinger]) until the user acts or it times out. - * On an unlocked phone that is in use, Android shows the heads-up notification - * instead and this activity never starts; the channel's alarm sound covers that. + * turns the screen on and shows over the lock screen like a clock alarm. On an + * unlocked phone that is in use, Android shows the heads-up notification instead + * and this activity never starts. + * + * The ring is [AlarmRingService]'s, not this screen's, so it sounds either way + * (LESSONS #65). This screen only rings itself (see [AlarmRinger]) when nothing + * else is: an alert delivered late by BootWorker, or one whose service start was + * refused. Leaving the screen ends the ring, whoever owns it. * * The body is the same nudge view a notification tap opens: it reads the kind * and id from this activity's intent extras (Hilt hands them to the ViewModel's @@ -60,15 +67,17 @@ class AlarmActivity : ComponentActivity() { override fun onStart() { super.onStart() + // The service is already ringing for an on-time alarm; ring here only when it isn't. // The memo's own tone when it has one; the default alarm tone otherwise. - ringer.start(intent.getStringExtra(REMINDER_VIEW_ARG_SOUND)?.let(Uri::parse)) + if (!AlarmRingService.isRinging()) { + ringer.start(intent.getStringExtra(REMINDER_VIEW_ARG_SOUND)?.let(Uri::parse)) + } } - // singleInstance activity: a redundant launch for the same ring (the notification's - // full-screen intent and AlarmReceiver's direct start both fire when the phone is - // locked) arrives here instead of creating a second instance. The ring is already - // going (AlarmRinger.start no-ops while a player is live), so this just keeps the - // extras current; nothing to restart. + // singleInstance activity: a second launch while the screen is up (a newer alarm's + // full-screen intent) arrives here instead of creating a second instance. The ring + // is already going (the service's, or AlarmRinger.start no-ops while a player is + // live), so this just keeps the extras current; nothing to restart. override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -80,7 +89,18 @@ class AlarmActivity : ComponentActivity() { override fun onStop() { ringer.stop() super.onStop() - if (!isChangingConfigurations) finish() + if (!isChangingConfigurations) { + // The notification stays behind for Snooze / Done; Done and Snooze on this + // screen clear it themselves. + alertNotifyId()?.let { AlarmRingService.silence(it, keepNotification = true) } + finish() + } + } + + private fun alertNotifyId(): Int? { + val kind = ReminderViewKind.fromRouteArg(intent.getStringExtra(REMINDER_VIEW_ARG_KIND)) ?: return null + val id = intent.getStringExtra(REMINDER_VIEW_ARG_ID) ?: return null + return NotificationHelper.notifyId(kind, id) } override fun onDestroy() { @@ -103,6 +123,6 @@ class AlarmActivity : ComponentActivity() { } private companion object { - val RING_TIMEOUT = 2.minutes + val RING_TIMEOUT = AlarmRingService.RING_TIMEOUT } } diff --git a/app/src/main/java/com/mapgie/dash/alarm/AlarmReceiver.kt b/app/src/main/java/com/mapgie/dash/alarm/AlarmReceiver.kt index ad05f73b..19c484e0 100644 --- a/app/src/main/java/com/mapgie/dash/alarm/AlarmReceiver.kt +++ b/app/src/main/java/com/mapgie/dash/alarm/AlarmReceiver.kt @@ -47,11 +47,12 @@ class AlarmReceiver : BroadcastReceiver() { val sound = runCatching { reminderRepository.loadReminders().firstOrNull { it.id == reminderId }?.sound }.getOrNull() - NotificationHelper.showReminderAlert(context, reminderId, subject, deliveryMode, taskId, featureWord, sound) - // Alarm style only: ring on the alarm stream even when the phone is - // unlocked, where Android shows a heads-up rather than launching the - // full-screen intent (NotificationHelper.startAlarmRingScreen). - NotificationHelper.startAlarmRingScreen(context, deliveryMode, ReminderViewKind.REMINDER, reminderId, subject, sound) + // The Alarm style rings from AlarmRingService, locked or not (LESSONS #65). + NotificationHelper.deliverOnTime( + context, deliveryMode, NotificationHelper.notifyId(ReminderViewKind.REMINDER, reminderId), + NotificationHelper.reminderAlert(context, reminderId, subject, deliveryMode, taskId, featureWord, sound), + sound, + ) try { // A repeating memo comes back with its next ring armed; a // once-only one is now spent and syncReminder just clears it. @@ -64,8 +65,10 @@ class AlarmReceiver : BroadcastReceiver() { } taskId != null -> { val taskTitle = intent.getStringExtra(NotificationHelper.EXTRA_TASK_TITLE) ?: "Task" - NotificationHelper.showTaskReminder(context, taskId, taskTitle, deliveryMode) - NotificationHelper.startAlarmRingScreen(context, deliveryMode, ReminderViewKind.TASK, taskId, taskTitle) + NotificationHelper.deliverOnTime( + context, deliveryMode, NotificationHelper.notifyId(ReminderViewKind.TASK, taskId), + NotificationHelper.taskReminder(context, taskId, taskTitle, deliveryMode), + ) try { // Mark reminded=true in Supabase so other clients know the alert was sent. taskRepository.markReminded(taskId) diff --git a/app/src/main/java/com/mapgie/dash/alarm/AlarmRingService.kt b/app/src/main/java/com/mapgie/dash/alarm/AlarmRingService.kt new file mode 100644 index 00000000..a1252167 --- /dev/null +++ b/app/src/main/java/com/mapgie/dash/alarm/AlarmRingService.kt @@ -0,0 +1,159 @@ +package com.mapgie.dash.alarm + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import kotlin.time.Duration.Companion.minutes + +/** + * Owns the Alarm style's ring: a foreground service that posts the alarm + * notification as its own and loops [AlarmRinger] on the alarm stream until the + * alarm is answered, dismissed, or times out. + * + * Why a service and not [AlarmActivity]: the activity only runs when Android + * lets it start. It launches the full-screen intent only on a locked or sleeping + * phone, and it blocks a background activity start from an alarm receiver once + * the app has been out of sight for more than a few seconds (LESSONS #65). A + * foreground service started from an exact alarm is a documented exemption, so + * the ring no longer depends on whether a screen was allowed to open. The ring + * screen still shows over the lock screen through the notification's + * full-screen intent; it is the answer UI, not the thing making the sound. + */ +class AlarmRingService : Service() { + + private val handler = Handler(Looper.getMainLooper()) + private var ringer: AlarmRinger? = null + private var ringingNotifyId: Int? = null + private val timeout = Runnable { halt(keepNotification = true) } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + instance = this + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION, Notification::class.java) } + if (intent == null || notification == null) { + stopSelf() + return START_NOT_STICKY + } + val notifyId = intent.getIntExtra(EXTRA_NOTIFY_ID, 0) + val soundUri = intent.getStringExtra(EXTRA_SOUND)?.let(Uri::parse) + + // A newer alarm replaces a ringing one: the older notification stays behind, + // unanswered, for Snooze / Done, and the ring restarts with the newer tone. + if (ringingNotifyId != null && ringingNotifyId != notifyId) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + } + val started = runCatching { + val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED + } else 0 + ServiceCompat.startForeground(this, notifyId, notification, type) + }.isSuccess + if (!started) { + // Refused after all: the alert still goes out as a plain notification. Its + // full-screen intent opens the ring screen, which rings itself, on a locked phone. + postPlain(notifyId, notification) + stopSelf() + return START_NOT_STICKY + } + + ringer?.stop() + ringer = AlarmRinger(applicationContext).also { it.start(soundUri) } + ringingNotifyId = notifyId + handler.removeCallbacks(timeout) + handler.postDelayed(timeout, RING_TIMEOUT.inWholeMilliseconds) + // Not sticky: a ring the system killed must not come back minutes later. + return START_NOT_STICKY + } + + override fun onDestroy() { + handler.removeCallbacks(timeout) + ringer?.stop() + ringer = null + ringingNotifyId = null + if (instance === this) instance = null + super.onDestroy() + } + + // Ends the ring. [keepNotification] leaves the alert in the shade for Snooze / + // Done (a timeout, the ring screen closed, the app opened from it); false takes + // it away with the ring (the alarm was answered, or the user swiped it). + private fun halt(keepNotification: Boolean) { + if (ringingNotifyId == null) return + handler.removeCallbacks(timeout) + ringer?.stop() + ringer = null + ringingNotifyId = null + ServiceCompat.stopForeground( + this, + if (keepNotification) ServiceCompat.STOP_FOREGROUND_DETACH else ServiceCompat.STOP_FOREGROUND_REMOVE, + ) + stopSelf() + } + + @SuppressLint("MissingPermission") + private fun postPlain(notifyId: Int, notification: Notification) { + runCatching { NotificationManagerCompat.from(this).notify(notifyId, notification) } + } + + companion object { + /** How long a ring nobody answers keeps going; the notification stays behind. */ + val RING_TIMEOUT = 2.minutes + + private const val EXTRA_NOTIFY_ID = "ring_notify_id" + private const val EXTRA_NOTIFICATION = "ring_notification" + private const val EXTRA_SOUND = "ring_sound" + + // Everything that silences a ring (the ring screen, the notification's actions, + // the in-app nudge) runs in this process, so a reference is all the plumbing + // needed. Main thread only. + @Volatile private var instance: AlarmRingService? = null + + /** True while a ring is sounding, so the ring screen does not start a second one. */ + fun isRinging(): Boolean = instance?.ringingNotifyId != null + + /** + * Starts the ring for the alert posted under [notifyId]. Only call it from a + * real-time alarm delivery (AlarmReceiver): that is what exempts the start + * from the background limits. Returns false when Android refuses it, so the + * caller can post the notification the old way. + */ + fun start(context: Context, notifyId: Int, notification: Notification, soundUri: String?): Boolean = + runCatching { + ContextCompat.startForegroundService( + context, + Intent(context, AlarmRingService::class.java) + .putExtra(EXTRA_NOTIFY_ID, notifyId) + .putExtra(EXTRA_NOTIFICATION, notification) + .apply { soundUri?.let { putExtra(EXTRA_SOUND, it) } }, + ) + }.isSuccess + + /** + * Silences the ring for the alert posted under [notifyId], if that is the one + * sounding. A ring for a different alert is left alone. + */ + fun silence(notifyId: Int, keepNotification: Boolean) { + Handler(Looper.getMainLooper()).post { + val service = instance ?: return@post + if (service.ringingNotifyId == notifyId) service.halt(keepNotification) + } + } + } +} diff --git a/app/src/main/java/com/mapgie/dash/alarm/AlarmScheduler.kt b/app/src/main/java/com/mapgie/dash/alarm/AlarmScheduler.kt index 8fdfcd39..3186675e 100644 --- a/app/src/main/java/com/mapgie/dash/alarm/AlarmScheduler.kt +++ b/app/src/main/java/com/mapgie/dash/alarm/AlarmScheduler.kt @@ -32,11 +32,15 @@ class AlarmScheduler @Inject constructor( /** * Arms a test ring [seconds] from now that travels the exact path a memo - * does: AlarmManager, AlarmReceiver, the notification, the full-screen ring. + * does: AlarmManager, AlarmReceiver, the notification, the ring service. * There is no record behind [TEST_REMINDER_ID]; the receiver and the ring * screen know to treat it as a stand-in. + * + * A minute, not seconds: an app the user left moments ago still counts as + * in use, so a quick test passes where a real memo, fired long after, fails + * (LESSONS #65). The delay lets the user put the app away first. */ - fun scheduleTestRing(subject: String, seconds: Long = 10) { + fun scheduleTestRing(subject: String, seconds: Long = TEST_RING_DELAY_SECONDS) { cancelReminder(TEST_REMINDER_ID) scheduleReminder(TEST_REMINDER_ID, subject, Instant.now().plusSeconds(seconds)) } @@ -175,5 +179,6 @@ class AlarmScheduler @Inject constructor( companion object { /** The id the Settings "test alarm" rings under; no stored record carries it. */ const val TEST_REMINDER_ID = "test-alarm" + const val TEST_RING_DELAY_SECONDS = 60L } } diff --git a/app/src/main/java/com/mapgie/dash/data/model/Reminder.kt b/app/src/main/java/com/mapgie/dash/data/model/Reminder.kt index 0f0fd384..f2a17406 100644 --- a/app/src/main/java/com/mapgie/dash/data/model/Reminder.kt +++ b/app/src/main/java/com/mapgie/dash/data/model/Reminder.kt @@ -152,6 +152,28 @@ fun nextOccurrence(after: Instant, timeOfDay: LocalTime, days: Set, z return null } +/** + * The ring a memo means when its time of day is set to [time] at [now]: the + * picked day at [time] while that is still ahead, otherwise the next [time] + * after [now] (today, or tomorrow once today's has gone). A clock app's rule: + * nobody sets a ring for a moment that has already passed, so changing only the + * time on yesterday's memo means today, not yesterday. + */ +fun ringForPickedTime(picked: ZonedDateTime, time: LocalTime, now: Instant): ZonedDateTime { + val at = time.withSecond(0).withNano(0) + val onPickedDay = picked.with(at) + if (onPickedDay.toInstant().isAfter(now)) return onPickedDay + return nextMorning(now, at, picked.zone).atZone(picked.zone) +} + +/** + * Whether a once-only memo can be saved with [ring]: it is still ahead, or it is + * the ring the memo opened with, so renaming a memo that has already rung is + * still allowed. A ring moved into the past would never fire. + */ +fun onceOnlyRingSavable(ring: Instant, opened: Instant, now: Instant): Boolean = + ring.isAfter(now) || ring == opened + /** The next ring of a repeating memo strictly after [after], keeping its local time of day. */ fun ReminderDto.nextOccurrenceAfter(after: Instant, zone: ZoneId = ZoneId.systemDefault()): Instant? { val time = timeOfDay(zone) ?: return null diff --git a/app/src/main/java/com/mapgie/dash/notification/DeliveryStyle.kt b/app/src/main/java/com/mapgie/dash/notification/DeliveryStyle.kt index 1f3784b4..fd2ad663 100644 --- a/app/src/main/java/com/mapgie/dash/notification/DeliveryStyle.kt +++ b/app/src/main/java/com/mapgie/dash/notification/DeliveryStyle.kt @@ -9,8 +9,8 @@ package com.mapgie.dash.notification * Robolectric: **which stored mode must ring on the phone's alarm audio stream.** * That is the crux of LESSONS #51 — a posted notification's sound can land on the * (often muted) notification stream whatever `USAGE_ALARM` the channel declares, - * so for the Alarm mode `AlarmReceiver` launches `AlarmActivity` itself and - * `AlarmRinger` plays under `USAGE_ALARM`. If the mapping below ever stops + * so for the Alarm mode `AlarmReceiver` hands the alert to `AlarmRingService`, whose + * `AlarmRinger` plays under `USAGE_ALARM` (LESSONS #65). If the mapping below ever stops * treating [DeliveryMode.ALARM] as an alarm, an unlocked phone falls silent again. */ enum class DeliveryStyle { ALARM, NOTIFICATION, SILENT } @@ -34,10 +34,21 @@ object DeliveryMode { /** * True for the one mode that must sound on the **alarm** audio stream rather - * than the notification stream. `AlarmReceiver` gates its direct - * `startAlarmRingScreen` launch on this, so keep it in lockstep with the real + * than the notification stream. `NotificationHelper.deliverOnTime` gates the + * `AlarmRingService` start on this, so keep it in lockstep with the real * ring behaviour: this returning false for [ALARM] is exactly the regression * that made the Alarm style silent on an unlocked phone (LESSONS #51). */ fun ringsOnAlarmStream(deliveryMode: String): Boolean = styleOf(deliveryMode) == DeliveryStyle.ALARM } + +/** + * The ids memo and task alerts are posted under. Everything that silences or + * clears an alert (the notification's actions, the ring screen, the in-app nudge, + * the ring service) must name the same id, and an installed app has alerts in the + * shade under these exact values, so they must not change. + */ +object AlertIds { + fun reminder(reminderId: String): Int = "reminder_$reminderId".hashCode() + fun task(taskId: String): Int = taskId.hashCode() +} diff --git a/app/src/main/java/com/mapgie/dash/notification/NotificationHelper.kt b/app/src/main/java/com/mapgie/dash/notification/NotificationHelper.kt index b2eff822..425840b9 100644 --- a/app/src/main/java/com/mapgie/dash/notification/NotificationHelper.kt +++ b/app/src/main/java/com/mapgie/dash/notification/NotificationHelper.kt @@ -1,6 +1,7 @@ package com.mapgie.dash.notification import android.annotation.SuppressLint +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -14,6 +15,7 @@ import com.mapgie.dash.MainActivity import com.mapgie.dash.R import com.mapgie.dash.alarm.AlarmActionReceiver import com.mapgie.dash.alarm.AlarmActivity +import com.mapgie.dash.alarm.AlarmRingService import com.mapgie.dash.ui.screens.reminder.REMINDER_VIEW_ARG_ID import com.mapgie.dash.ui.screens.reminder.REMINDER_VIEW_ARG_KIND import com.mapgie.dash.ui.screens.reminder.REMINDER_VIEW_ARG_SOUND @@ -204,12 +206,9 @@ object NotificationHelper { DeliveryMode.ringsOnAlarmStream(deliveryMode) // The Alarm style's ring screen: AlarmActivity turns the screen on over the lock - // screen and rings (AlarmRinger, USAGE_ALARM) until answered. This same intent is - // used two ways: as the notification's full-screen intent (below), and started - // directly by AlarmReceiver when the alarm fires (startAlarmRingScreen). CLEAR_TASK - // replaces a still-ringing alarm with the newer one rather than stacking two ringing - // screens; AlarmActivity is launchMode="singleInstance" so a redundant launch (both - // paths firing when the phone is locked) reuses the one instance instead of double-ringing. + // screen and shows Done / Snooze. It is the notification's full-screen intent, so + // Android opens it on a locked or sleeping phone; the ring itself is AlarmRingService's. + // CLEAR_TASK replaces a screen still up for an older alarm with the newer one. fun alarmActivityIntent( context: Context, kind: ReminderViewKind, @@ -238,53 +237,69 @@ object NotificationHelper { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + /** The id a memo or task alert is posted under (see [AlertIds]). */ + fun notifyId(kind: ReminderViewKind, id: String): Int = when (kind) { + ReminderViewKind.REMINDER -> AlertIds.reminder(id) + ReminderViewKind.TASK -> AlertIds.task(id) + } + /** - * Brings up the Alarm style's full-screen ring the moment the alarm fires, so it - * sounds on the alarm stream via [com.mapgie.dash.alarm.AlarmRinger] regardless of - * lock state. A no-op unless [deliveryMode] is the Alarm style. + * Delivers an alert the moment its alarm fires. For the Alarm style the ring + * goes to [com.mapgie.dash.alarm.AlarmRingService], which posts [notification] + * as its own and rings on the alarm stream whether or not the phone is locked + * (LESSONS #65); every other style, or a refused service start, posts it plainly. * - * Why this is needed on top of the notification's full-screen intent: Android - * launches a full-screen intent only when the phone is locked or asleep. Awake and - * unlocked it shows a heads-up instead, and a posted notification's sound plays on - * the notification stream on most devices, whatever USAGE_ALARM the channel declares - * (LESSONS #52). That stream can be muted while the alarm stream is up, so the Alarm - * style would ring silently. Starting the activity ourselves runs AlarmRinger, which - * plays a MediaPlayer under USAGE_ALARM and therefore always uses the alarm stream. - * - * Only call this from a real-time alarm delivery (AlarmReceiver): a background - * activity start is granted only for a short window after an exact alarm fires. The - * runCatching keeps a denied start (e.g. an OEM that ignores the exemption) from - * costing the notification, which still carries the full-screen intent as a fallback. + * Only call this from a real-time alarm delivery (AlarmReceiver): starting the + * service from the background is allowed because an exact alarm just fired. A + * late delivery (BootWorker) posts with [showReminderAlert] / [showTaskReminder]. */ - fun startAlarmRingScreen( + @SuppressLint("MissingPermission") + fun deliverOnTime( context: Context, deliveryMode: String, - kind: ReminderViewKind, - id: String, - subject: String, + notifyId: Int, + notification: Notification, soundUri: String? = null, ) { - if (!isAlarmStyle(deliveryMode)) return - runCatching { context.startActivity(alarmActivityIntent(context, kind, id, subject, soundUri)) } + if (isAlarmStyle(deliveryMode) && AlarmRingService.start(context, notifyId, notification, soundUri)) return + NotificationManagerCompat.from(context).notify(notifyId, notification) } + // Swiping away a ringing alert ends the ring with it (Android 14+ lets the user + // swipe a foreground service's notification). + private fun ringDismissedIntent(context: Context, notifyId: Int): PendingIntent = PendingIntent.getBroadcast( + context, "ring_dismissed_$notifyId".hashCode(), + Intent(context, AlarmActionReceiver::class.java).apply { + action = AlarmActionReceiver.ACTION_RING_DISMISSED + putExtra(AlarmActionReceiver.EXTRA_NOTIFY_ID, notifyId) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + /** * Applies the per-style presentation: the Alarm style is an alarm to the * system (full-screen intent, CATEGORY_ALARM); the others are plain * reminders on their channel. [fullScreen] is only built for the Alarm style. */ private fun NotificationCompat.Builder.styledFor( + context: Context, deliveryMode: String, + notifyId: Int, fullScreen: () -> PendingIntent, ): NotificationCompat.Builder = if (isAlarmStyle(deliveryMode)) { setCategory(NotificationCompat.CATEGORY_ALARM) setFullScreenIntent(fullScreen(), true) + setDeleteIntent(ringDismissedIntent(context, notifyId)) } else { setCategory(NotificationCompat.CATEGORY_REMINDER) } @SuppressLint("MissingPermission") fun showTaskReminder(context: Context, taskId: String, taskTitle: String, deliveryMode: String = "NOTIFICATION") { + NotificationManagerCompat.from(context).notify(AlertIds.task(taskId), taskReminder(context, taskId, taskTitle, deliveryMode)) + } + + fun taskReminder(context: Context, taskId: String, taskTitle: String, deliveryMode: String = "NOTIFICATION"): Notification { val channelId = channelId(ReminderKind.TASK_REMINDER, deliveryMode) val openIntent = PendingIntent.getActivity( context, taskId.hashCode(), @@ -315,25 +330,22 @@ object NotificationHelper { doneIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = NotificationCompat.Builder(context, channelId) + return NotificationCompat.Builder(context, channelId) .setSmallIcon(android.R.drawable.ic_popup_reminder) .setContentTitle("Task reminder") .setContentText(taskTitle) .setContentIntent(openIntent) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) - .styledFor(deliveryMode) { + .styledFor(context, deliveryMode, AlertIds.task(taskId)) { fullScreenIntent(context, ReminderViewKind.TASK, taskId, taskTitle, "fullscreen_$taskId".hashCode()) } .addAction(0, "Snooze 15 min", snoozePI) .addAction(0, "Done", donePI) .build() - - NotificationManagerCompat.from(context).notify(taskId.hashCode(), notification) } @SuppressLint("MissingPermission") - /** [title] is the user's word for the feature ("Reminder", "Alarm", "Memo"); callers read it from settings. */ fun showReminderAlert( context: Context, reminderId: String, @@ -343,8 +355,24 @@ object NotificationHelper { title: String = "Reminder", soundUri: String? = null, ) { + NotificationManagerCompat.from(context).notify( + AlertIds.reminder(reminderId), + reminderAlert(context, reminderId, subject, deliveryMode, taskId, title, soundUri), + ) + } + + /** [title] is the user's word for the feature ("Reminder", "Alarm", "Memo"); callers read it from settings. */ + fun reminderAlert( + context: Context, + reminderId: String, + subject: String, + deliveryMode: String = "NOTIFICATION", + taskId: String? = null, + title: String = "Reminder", + soundUri: String? = null, + ): Notification { val channelId = channelId(ReminderKind.TASK_REMINDER, deliveryMode) - val notifyId = ("reminder_$reminderId").hashCode() + val notifyId = AlertIds.reminder(reminderId) val openIntent = PendingIntent.getActivity( context, notifyId, Intent(context, MainActivity::class.java).apply { @@ -375,21 +403,19 @@ object NotificationHelper { doneIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = NotificationCompat.Builder(context, channelId) + return NotificationCompat.Builder(context, channelId) .setSmallIcon(android.R.drawable.ic_popup_reminder) .setContentTitle(title) .setContentText(subject) .setContentIntent(openIntent) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) - .styledFor(deliveryMode) { + .styledFor(context, deliveryMode, notifyId) { fullScreenIntent(context, ReminderViewKind.REMINDER, reminderId, subject, "fullscreen_reminder_$reminderId".hashCode(), soundUri) } .addAction(0, "Snooze 15 min", snoozePI) .addAction(0, "Done", donePI) .build() - - NotificationManagerCompat.from(context).notify(notifyId, notification) } @SuppressLint("MissingPermission") diff --git a/app/src/main/java/com/mapgie/dash/ui/components/AddReminderSheet.kt b/app/src/main/java/com/mapgie/dash/ui/components/AddReminderSheet.kt index 8b7cc1d9..435392e3 100644 --- a/app/src/main/java/com/mapgie/dash/ui/components/AddReminderSheet.kt +++ b/app/src/main/java/com/mapgie/dash/ui/components/AddReminderSheet.kt @@ -80,9 +80,11 @@ import com.mapgie.dash.data.model.formatRingTime import com.mapgie.dash.data.model.isTagAlarm import com.mapgie.dash.data.model.nextMorning import com.mapgie.dash.data.model.nextOccurrence +import com.mapgie.dash.data.model.onceOnlyRingSavable import com.mapgie.dash.data.model.parseRepeatDays import com.mapgie.dash.data.model.parseRingTimes import com.mapgie.dash.data.model.remindAtInstant +import com.mapgie.dash.data.model.ringForPickedTime import com.mapgie.dash.data.model.suggestTagId import com.mapgie.dash.ui.components.core.LocalReminderLabel import com.mapgie.dash.ui.components.core.MetaCaption @@ -271,7 +273,10 @@ fun AddReminderSheet( followUps = if (tagAlarmOn) followUps.map { formatRingTime(it) } else emptyList(), ) val isDirty = currentDraft.differsFrom(opened) - val canSave = subject.isNotBlank() && (tagAlarmOn || !(repeatOn && days.isEmpty())) + val canSave = subject.isNotBlank() && (tagAlarmOn || !(repeatOn && days.isEmpty())) && + (tagAlarmOn || repeatOn || onceOnlyRingSavable( + Instant.ofEpochMilli(currentDraft.ringAtEpochMillis), Instant.ofEpochMilli(opened.ringAtEpochMillis), now, + )) // The tag the phone just read while this sheet was waiting for one. A tag has // one job: an id a chore or another tag-alarm owns is refused, with the owner named. @@ -911,7 +916,8 @@ fun AddReminderSheet( initialHour = ringAt.hour, initialMinute = ringAt.minute, onConfirm = { h, m -> - ringAt = ringAt.withHour(h).withMinute(m).withSecond(0).withNano(0) + // A time already gone today moves to its next occurrence (ringForPickedTime). + ringAt = ringForPickedTime(ringAt, LocalTime.of(h, m), Instant.now()) showTimePicker = false }, onDismiss = { showTimePicker = false } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/reminder/ReminderViewViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/screens/reminder/ReminderViewViewModel.kt index 47d25aa8..e2da76bd 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/reminder/ReminderViewViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/reminder/ReminderViewViewModel.kt @@ -6,6 +6,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.mapgie.dash.alarm.AlarmRingService import com.mapgie.dash.alarm.AlarmScheduler import com.mapgie.dash.data.model.ReminderLabelStyle import com.mapgie.dash.data.model.isTagAlarm @@ -14,6 +15,7 @@ import com.mapgie.dash.data.model.reminderInstant import com.mapgie.dash.data.preferences.SettingsRepository import com.mapgie.dash.data.repository.ReminderRepository import com.mapgie.dash.data.repository.TaskRepository +import com.mapgie.dash.notification.NotificationHelper import com.mapgie.dash.tagalarm.TagAlarmService import com.mapgie.dash.data.supabase.userFacingMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -240,12 +242,11 @@ class ReminderViewViewModel @Inject constructor( _uiState.update { it.copy(finished = true) } } - // Same notify ids NotificationHelper posts under. + // Answering ends the ring and clears the alert. A ringing alert's notification + // belongs to the ring service, which removes it; cancel covers the rest. private fun cancelNotification(kind: ReminderViewKind, id: String) { - val notifyId = when (kind) { - ReminderViewKind.REMINDER -> ("reminder_$id").hashCode() - ReminderViewKind.TASK -> id.hashCode() - } + val notifyId = NotificationHelper.notifyId(kind, id) + AlarmRingService.silence(notifyId, keepNotification = false) NotificationManagerCompat.from(context).cancel(notifyId) } } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt index 812e3856..d8651fbb 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt @@ -76,6 +76,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.mapgie.dash.alarm.AlarmScheduler import com.mapgie.dash.BuildConfig import com.mapgie.dash.data.model.AddMenuOption import com.mapgie.dash.data.model.CadenceBucket @@ -1090,23 +1091,24 @@ private fun RemindersSubScreen( "If the channel says silent, tap it and turn the sound back on in system settings." ) AccentPillButton( - text = "Ring a test ${featureWord.lowercase()} in 10 seconds", + text = "Ring a test ${featureWord.lowercase()} in 1 minute", onClick = { viewModel.ringTestAlarm("Test ${featureWord.lowercase()}") - testArmedAt = java.time.LocalTime.now().plusSeconds(10) + testArmedAt = java.time.LocalTime.now().plusSeconds(AlarmScheduler.TEST_RING_DELAY_SECONDS) }, ) SettingsCaption( testArmedAt?.let { at -> val armed = "Armed for ${at.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"))}. " + - "It takes the same path a real ${featureWord.lowercase()} does. " + "Leave the app or lock the phone now, so it rings the way a real ${featureWord.lowercase()} does. " armed + when (currentDeliveryMode) { "ALARM" -> "The Alarm style rings on the alarm stream and shows its full-screen screen whether the phone is locked or unlocked." "SILENT" -> "The Silent style posts quietly: no sound or vibration by design." else -> "Watch for the heads-up notification and its sound." } } ?: "Takes the same path a real ${featureWord.lowercase()} does: exact alarm, receiver, notification, " + - "and, for the Alarm style, the full-screen ring, whether the phone is locked or not.", + "and, for the Alarm style, the ring on the alarm stream, whether the phone is locked or not. " + + "The minute gives you time to leave the app, which is when a real one fires.", modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsViewModel.kt index b5a378e9..1cfe0899 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsViewModel.kt @@ -61,7 +61,7 @@ class SettingsViewModel @Inject constructor( private val alarmScheduler: AlarmScheduler, ) : ViewModel() { - /** Settings › Reminders & alerts: ring a test alarm in ten seconds through the real alarm path. */ + /** Settings › Reminders & alerts: ring a test alarm in a minute through the real alarm path. */ fun ringTestAlarm(subject: String) { alarmScheduler.scheduleTestRing(subject) } diff --git a/app/src/test/java/com/mapgie/dash/data/model/ReminderPickedTimeTest.kt b/app/src/test/java/com/mapgie/dash/data/model/ReminderPickedTimeTest.kt new file mode 100644 index 00000000..4078c44b --- /dev/null +++ b/app/src/test/java/com/mapgie/dash/data/model/ReminderPickedTimeTest.kt @@ -0,0 +1,77 @@ +package com.mapgie.dash.data.model + +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The edit sheet's time row: a once-only memo's ring rolls forward to the next + * occurrence of the picked time instead of landing in the past. + */ +class ReminderPickedTimeTest { + + private val zone = ZoneId.of("Europe/London") + // Friday 25 September 2026, 18:00 local. + private val now = ZonedDateTime.of(2026, 9, 25, 18, 0, 0, 0, zone).toInstant() + + private fun at(date: LocalDate, hour: Int, minute: Int) = + ZonedDateTime.of(date, LocalTime.of(hour, minute), zone) + + private val yesterday = LocalDate.of(2026, 9, 24) + private val today = LocalDate.of(2026, 9, 25) + private val tomorrow = LocalDate.of(2026, 9, 26) + private val nextTuesday = LocalDate.of(2026, 9, 29) + + @Test + fun `a time still ahead today on yesterday's memo rings today`() { + val ring = ringForPickedTime(at(yesterday, 22, 33), LocalTime.of(18, 35), now) + assertEquals(at(today, 18, 35), ring) + } + + @Test + fun `a time already gone today rings tomorrow`() { + val ring = ringForPickedTime(at(yesterday, 22, 33), LocalTime.of(7, 0), now) + assertEquals(at(tomorrow, 7, 0), ring) + } + + @Test + fun `a memo set for a future day keeps its day when the time changes`() { + val ring = ringForPickedTime(at(nextTuesday, 9, 0), LocalTime.of(7, 30), now) + assertEquals(at(nextTuesday, 7, 30), ring) + } + + @Test + fun `a time later today on today's memo stays today`() { + val ring = ringForPickedTime(at(today, 12, 0), LocalTime.of(21, 15), now) + assertEquals(at(today, 21, 15), ring) + } + + @Test + fun `picking the current minute rolls to tomorrow rather than ringing in the past`() { + val ring = ringForPickedTime(at(today, 12, 0), LocalTime.of(18, 0), now) + assertEquals(at(tomorrow, 18, 0), ring) + } + + @Test + fun `a ring still ahead can be saved`() { + val opened = at(yesterday, 22, 33).toInstant() + assertTrue(onceOnlyRingSavable(at(today, 18, 35).toInstant(), opened, now)) + } + + @Test + fun `a ring moved into the past cannot be saved`() { + val opened = at(tomorrow, 9, 0).toInstant() + assertFalse(onceOnlyRingSavable(at(yesterday, 9, 0).toInstant(), opened, now)) + } + + @Test + fun `a memo that has already rung can still be saved with its ring unchanged`() { + val opened = at(yesterday, 22, 33).toInstant() + assertTrue(onceOnlyRingSavable(opened, opened, now)) + } +} diff --git a/app/src/test/java/com/mapgie/dash/notification/DeliveryStyleTest.kt b/app/src/test/java/com/mapgie/dash/notification/DeliveryStyleTest.kt index f6340697..648b4cbb 100644 --- a/app/src/test/java/com/mapgie/dash/notification/DeliveryStyleTest.kt +++ b/app/src/test/java/com/mapgie/dash/notification/DeliveryStyleTest.kt @@ -2,6 +2,7 @@ package com.mapgie.dash.notification import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -9,8 +10,8 @@ import org.junit.Test * Guards the fix for the silent Alarm style (LESSONS #51): the Alarm delivery * mode, and only the Alarm mode, must ring on the phone's alarm audio stream. * - * `AlarmReceiver` launches `AlarmActivity` (which runs `AlarmRinger` under - * `USAGE_ALARM`) only when [DeliveryMode.ringsOnAlarmStream] is true. If that ever + * `NotificationHelper.deliverOnTime` starts `AlarmRingService` (which runs `AlarmRinger` + * under `USAGE_ALARM`) only when [DeliveryMode.ringsOnAlarmStream] is true. If that ever * stops holding for [DeliveryMode.ALARM], an Alarm-style reminder falls silent on * an unlocked phone whose notification volume is muted, which is the exact bug the * fix cured. These assertions are the tripwire. @@ -46,4 +47,17 @@ class DeliveryStyleTest { assertEquals("NOTIFICATION", DeliveryMode.NOTIFICATION) assertEquals("SILENT", DeliveryMode.SILENT) } + + @Test + fun `a memo and a task with the same id are posted under different alert ids`() { + assertNotEquals(AlertIds.reminder("abc"), AlertIds.task("abc")) + } + + @Test + fun `alert ids keep the values alerts already in the shade were posted under`() { + // Done, Snooze and the ring service find an alert by this id; a change would + // strand alerts posted by the previous version. + assertEquals("reminder_abc".hashCode(), AlertIds.reminder("abc")) + assertEquals("abc".hashCode(), AlertIds.task("abc")) + } } diff --git a/changelog/unreleased/alarm-ring-service.json b/changelog/unreleased/alarm-ring-service.json new file mode 100644 index 00000000..354ddd8a --- /dev/null +++ b/changelog/unreleased/alarm-ring-service.json @@ -0,0 +1,11 @@ +{ + "bump": "patch", + "changed": [ + "The test ring in Reminders & alerts now fires after a minute, so you can leave the app first and hear it the way a real alarm rings", + "Changing a reminder's time to one that has already passed today moves it to the next day, instead of leaving it on a date that has gone" + ], + "fixed": [ + "Alarm-style reminders ring again when the phone is unlocked or in vibrate mode, instead of arriving as a silent notification", + "A reminder can no longer be saved with a time in the past, where it would never ring" + ] +}