A learning project: a small Kotlin + Jetpack Compose reminder app. The UI is simple on purpose. The point is Android system design — how a reminder survives app close, process death, and device reboot.
Problem: How do we reliably schedule reminders that survive app closure, process death, and device reboot?
Answer in this app:
- Room is the source of truth. Persist the reminder first.
- AlarmManager is only a wake-up timer. Schedule after insert, using the Room id as
PendingIntentrequestCode. - On reboot and on every process start, read
PENDINGrows from Room and schedule them again.
- Install Android Studio (latest stable).
- File → Open and select this folder (
ReminderApp). - Wait for Gradle sync to finish.
- An Android emulator (API 24+) or a physical phone with USB debugging.
- Notifications and exact alarms are easier to judge on a real device.
- For reboot tests you need a device or emulator you can restart.
- Click Run (green play button) or press
Ctrl+R/⌘R. - Select the app module
:app. - On first launch, Android 13+ will ask for notification permission. Tap Allow.
- If a banner says exact alarms are off, tap Settings and allow the app to schedule exact alarms.
From the command line:
./gradlew :app:installDebugThen open ReminderApp on the device.
- Tap the + button.
- Enter a title.
- Tap the date button → pick a date → OK.
- Tap the time button → pick a time a few minutes in the future → OK.
- Tap Save.
- You return to the list. Status is
PENDING.
Saving a time in the past, or a blank title, shows an error and writes nothing.
On a PENDING card:
- Mark Complete → status
COMPLETED, alarm cancelled. - Cancel → status
CANCELLED, alarm cancelled.
The row stays in the list so you can see history.
- Tap a
PENDINGcard. - Change title and/or date/time.
- Tap Save.
The old alarm is cancelled, Room is updated, then a new alarm is scheduled with the same id.
When the alarm fires you get a notification with the title plus:
- Mark Complete
- Cancel
Those update Room even if you never open the app.
Do these in order. They are the actual constraints this project is built around.
- Create a reminder 1–2 minutes ahead.
- Press Home (do not Force Stop).
- Wait. You should get a notification.
- Tap Mark Complete. Reopen the app — status is
COMPLETED.
- Create a reminder a few minutes ahead.
- In Android Studio, click Stop (red square) to kill the process.
- Reopen the app. The row is still there (Room).
- Wait for the time. The notification should still appear (AlarmManager is OS-owned).
- Create a reminder several minutes ahead.
- Reboot the phone/emulator.
- Unlock after boot. Wait for the scheduled time.
- The notification should still appear (
BootReceiverrebuilt the alarm from Room).
Create two reminders for the same minute. Both should notify. Each alarm uses requestCode = reminder.id.
Create a reminder, then tap Cancel before it fires. You should not get a notification. If the alarm was already being delivered, ReminderReceiver re-reads Room and skips non-PENDING rows.
- Deny notifications (or revoke in system Settings).
- Create a reminder anyway — it still saves.
- A banner is shown on the list.
- When the time hits, the app must not crash (
notify()is caught).
- In system Settings, turn off exact alarms for ReminderApp.
- Create a reminder. A banner is shown.
- The reminder is still saved. The alarm may fire late (inexact fallback).
Unit tests are JVM tests. They use fakes for Room and AlarmManager — no emulator required.
./gradlew :app:testDebugUnitTestIn Android Studio: right-click app/src/test → Run Tests.
| Test | What it proves |
|---|---|
CreateReminderUseCaseTest |
Persist then schedule; blank title; time in the past; two reminders at the same timestamp get distinct ids |
DeleteReminderUseCaseTest |
Status becomes CANCELLED and the alarm is cancelled |
CompleteReminderUseCaseTest |
Status becomes COMPLETED and the alarm is cancelled |
UpdateReminderUseCaseTest |
Room update then alarm replace; past time does not write |
RescheduleRemindersUseCaseTest |
After "reboot", only future PENDING rows are scheduled; same id is not duplicated |
ReminderScheduler is an interface so tests can use FakeReminderScheduler.
app/src/main/java/com/reminder/app/
├── data/ Room + repository implementation
├── domain/ models, repository contract, use cases
├── scheduler/ ReminderScheduler + AlarmManager implementation
├── receiver/ alarm, boot, notification-action BroadcastReceivers
├── notification/ NotificationCompat + channel
├── presentation/ Compose screens + ViewModels (StateFlow)
└── di/ Hilt modules
Compose UI
→ ViewModel (StateFlow)
→ Use case
→ Room (source of truth)
→ ReminderScheduler (AlarmManager) // after persist
→ ReminderReceiver
→ re-read Room
→ notification if still PENDING
MVVM + a thin use-case layer. No BaseViewModel. Hilt constructor injection.
Save
→ CreateReminderUseCase
1. Reject blank title / time in the past
2. repository.create(...) // Room INSERT, get id
3. scheduler.schedule(...) // AlarmManager
→ PendingIntent requestCode = reminder.id
FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE
→ at triggerTimeMillis
→ ReminderReceiver.goAsync()
load Room; if PENDING → show notification
Persist then schedule. If the process dies after insert, the next app start / reboot recovery schedules from Room.
BOOT_COMPLETED
→ BootReceiver
→ RescheduleRemindersUseCase
PENDING rows where triggerTimeMillis > now
scheduler.schedule(each)
The same use case runs in ReminderApplication.onCreate. That covers force-stop: Android wipes alarms and will not deliver BOOT_COMPLETED until the user opens the app.
Scheduling the same id twice replaces the previous alarm (no duplicates).
| Need | AlarmManager | Room |
|---|---|---|
| List reminders in the UI | Cannot query | Flow from DAO |
| Survive process death | Yes (OS-owned) | Yes (on disk) |
| Survive reboot | No | Yes |
| Know cancelled vs pending | No | ReminderStatus |
| Edit the time | Cancel + reschedule | Update row, then reschedule |
The UI never treats in-memory state as truth. ReminderListViewModel collects observeReminders(). After process death, a new process collects again.
When an alarm fires, ReminderReceiver re-reads Room. Cancelled/completed rows do not notify.
| Approach | Process death | Exact wall-clock | Reboot | Fit for reminders? |
|---|---|---|---|---|
Coroutine delay |
No | While alive | No | No |
Handler |
No | While alive | No | No |
| WorkManager | Yes | No (deferrable) | Yes | Poor |
| AlarmManager | Yes | Yes, with caveats | No (need BootReceiver) |
Yes |
WorkManager is for deferrable work. A reminder is a wall-clock event. This app uses AlarmManager.RTC_WAKEUP.
- Exact (
setExactAndAllowWhileIdle): near the chosen time, including Doze. Best UX. - Inexact (
setAndAllowWhileIdle): may be batched; can be minutes late; rate-limited in idle.
This app never assumes exact alarms are available:
- API 31+:
canScheduleExactAlarms(). If false → inexact fallback + list banner. - API 23–30:
setExactAndAllowWhileIdle(no special permission).
Android 14+ denies SCHEDULE_EXACT_ALARM by default. This project does not declare USE_EXACT_ALARM, so the fallback path stays real.
| Permission | Why |
|---|---|
POST_NOTIFICATIONS |
Android 13+ runtime permission. Without it, notify() throws. We catch that. |
SCHEDULE_EXACT_ALARM |
Android 12+ special app-op (not a normal runtime permission). |
RECEIVE_BOOT_COMPLETED |
Lets BootReceiver run after reboot. |
Receivers:
ReminderReceiver/ReminderActionReceiver:exported="false".BootReceiver:exported="true"so the system can sendBOOT_COMPLETED.
- Time in the past — rejected; nothing written.
- Two reminders at the same time — distinct
requestCodes (reminder.id). - Reboot before trigger —
BootReceiverrestores futurePENDINGrows. - Process killed — Room + OS-owned alarm; reschedule on next start as a safety net.
- User cancels — Room
CANCELLED, then cancel alarm. - User completes — Room
COMPLETED, then cancel alarm. - Alarm fires after cancel — receiver re-reads Room; skips non-
PENDING. - Exact alarm unavailable — inexact fallback + banner.
- Notification permission denied — save still works;
notify()does not crash. - Time edited — update Room, cancel old alarm, schedule new one with the same id.
Force-stop: alarms are cleared and BOOT_COMPLETED will not run until the user opens the app. Reschedule-on-start handles this.
Reminder(
id: Long,
title: String,
triggerTimeMillis: Long,
status: ReminderStatus // PENDING | COMPLETED | CANCELLED
)