diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 62fbd56..8f19508 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 8931a2c..adb1b08 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 #66.** 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 #66. + 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 @@ -1486,3 +1490,41 @@ had added yet. The raw line means nothing to a user, so `userFacingMessage` maps any "schema cache" error to "re-run supabase/schema.sql" (the schema is idempotent and adds missing columns with `ADD COLUMN IF NOT EXISTS`). The app cannot run DDL itself, so pointing at the fix is the most it can do. + +## 66. 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 68026b7..6328495 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 eda677a..82ffa25 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 02ea212..6a99541 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 910f741..88fb6b8 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 #66). 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 ad05f73..b03235e 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 #66). + 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 0000000..edd1e58 --- /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 #66). 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 8fdfcd3..581bdef 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 #66). 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 0f0fd38..f2a1740 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 1f3784b..5bfcbd5 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 #66). 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 b2eff82..fe2d031 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 #66); 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 8b7cc1d..435392e 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 47d25aa..e2da76b 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 812e385..d8651fb 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 b5a378e..1cfe089 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 0000000..4078c44 --- /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 f634069..648b4cb 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 0000000..354ddd8 --- /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" + ] +}