Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Name>SubScreen.kt`; controls in `CozyControls.kt` | Its own `<Name>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` |
Expand Down Expand Up @@ -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

Expand Down
42 changes: 42 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
12 changes: 12 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
the user can revoke this per app; Settings > Reminders & alerts shows the
state and deep-links to the toggle. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- The Alarm style rings from a foreground service (AlarmRingService) so it sounds
on the alarm stream whether or not Android lets the ring screen open: an
activity started from an alarm receiver is blocked once the app has been in
the background for a few seconds, a foreground service started from an exact
alarm is not (LESSONS #66). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- The service type Android 14+ reserves for apps holding exact-alarm permission
that keep an alarm ringing from a foreground service. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
<application
android:name=".DashApplication"
Expand Down Expand Up @@ -46,6 +55,9 @@
android:launchMode="singleInstance"
android:showWhenLocked="true" android:turnScreenOn="true"
android:theme="@style/Theme.Dash" />
<!-- Rings the Alarm style on the alarm stream until answered or timed out. -->
<service android:name=".alarm.AlarmRingService" android:exported="false"
android:foregroundServiceType="systemExempted" />
<receiver android:name=".alarm.AlarmReceiver" android:exported="false" />
<receiver android:name=".alarm.AlarmActionReceiver" android:exported="false" />
<receiver android:name=".alarm.BootReceiver" android:exported="true">
Expand Down
11 changes: 8 additions & 3 deletions app/src/main/java/com/mapgie/dash/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
27 changes: 23 additions & 4 deletions app/src/main/java/com/mapgie/dash/alarm/AlarmActionReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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"
}
}
46 changes: 33 additions & 13 deletions app/src/main/java/com/mapgie/dash/alarm/AlarmActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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() {
Expand All @@ -103,6 +123,6 @@ class AlarmActivity : ComponentActivity() {
}

private companion object {
val RING_TIMEOUT = 2.minutes
val RING_TIMEOUT = AlarmRingService.RING_TIMEOUT
}
}
Loading
Loading