Calendula now posts event reminders itself (the Etar model): the provider schedules the alarms and broadcasts EVENT_REMINDER, but a calendar app must turn them into visible notifications — essential for users whose only calendar app this is. A manifest-registered, exported receiver (data scheme content://com.android.calendar) wakes us at reminder time; no foreground service, no own alarm scheduling. Delivery path (data/reminders/): EventReminderReceiver (Hilt, goAsync) → ReminderAlertStore queries CalendarAlerts for STATE_SCHEDULED rows with ALARM_TIME <= now → ReminderNotifier posts one notification per alert on a dedicated high-importance channel, then best-effort marks rows FIRED (needs WRITE_CALENDAR; without it a re-broadcast silently replaces — tag per alert + setOnlyAlertOnce). Swiped notifications never return: FIRED rows are never re-queried, so no dismiss-intent machinery. Research (AOSP CalendarAlarmManager): the provider creates alert rows only for METHOD_ALERT reminders, so the email-reminder filter happens upstream. Tapping opens the event's detail screen: MainActivity is singleTop now, parses eventId/begin/end extras (onCreate + onNewIntent) into Compose state, and CalendarHost consumes the key exactly like an event tap. Onboarding gained a one-time second step after the calendar grant (shared OnboardingScaffold extracted from PermissionScreen): explains delivery, warns that a second calendar app with notifications on duplicates reminders, requests POST_NOTIFICATIONS (dialog on API 33+ only; minSdk 29). "Not now" turns the feature off; reminders default ON. Settings mirrors the toggle in a new Notifications section with the duplicate hint, and re-requests the permission when enabling. Strings DE+EN. Deliberately deferred (roadmap): snooze/dismiss actions, BOOT_COMPLETED / exact-alarm scheduling, battery-exemption prompts. Tests: reminderTimeText (all-day UTC-midnight reading, exclusive end day, midnight-crossing ranges), reminders/onboarding pref round-trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.4 KiB
Calendula - Plan 04: Reminder Notifications (v1.4)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Calendula stellt Erinnerungen selbst als Notification zu (Etar-Modell).
Der Provider plant die Alarme und broadcastet
android.intent.action.EVENT_REMINDER — die sichtbare Notification postet er
nicht, das muss eine Kalender-App tun. Für Nutzer, deren einzige
Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have.
./gradlew lint test assembleDebug bleibt grün; Release erst nach
On-Device-Review.
Architecture: Eigenes kleines Datenmodul data/reminders/ neben
data/calendar/ — der Receiver braucht weder Repository noch Flows. Schichtung
wie gehabt: EventReminderReceiver (Hilt-EntryPoint) →
ReminderAlertStore (Interface, Android-Impl auf CalendarAlerts) →
ReminderNotifier (NotificationManager). Domain bleibt pure Kotlin
(ReminderAlert-Modell, JVM-testbare Textformatierung).
Recherche-Befunde (AOSP CalendarAlarmManager + Etar, 2026-06-11):
- Der Provider legt
CalendarAlerts-Rows nur fürMETHOD_ALERT-Reminder an (AOSP-Query:AND method=1). Der im Roadmap-Eintrag geforderte METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin alsMETHOD_ALERT. - Der Broadcast ist implizit (Action +
content://com.android.calendar/…-URI, ExtraalarmTime). Etars Manifest-Receiver istexported="true"mit<data android:scheme="content"/>— das übernehmen wir (plus Host, enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst "fällig & noch SCHEDULED". - Etar postet aus dem Zustand
SCHEDULED ∪ FIREDund verwaltet Dismiss über eigene Services. Wir vereinfachen: nurSTATE_SCHEDULED AND alarmTime <= nowposten, danach best-effort aufFIREDsetzen (brauchtWRITE_CALENDAR;SecurityExceptionwird geschluckt). Weggewischte Notifications kommen so nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an. Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert +setOnlyAlertOnce).
Leitentscheidungen:
- Kein eigenes Alarm-Scheduling (kein
SCHEDULE_EXACT_ALARM, keinBOOT_COMPLETED, kein WorkManager): Zustellung hängt am Provider-Broadcast. Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption). - Toggle default ON, Onboarding-Schritt danach: Nach dem Kalender-Grant
folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission-
Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit
aktiven Notifications), fragt
POST_NOTIFICATIONSan (nur API 33+ zeigt einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt erscheint genau einmal (reminder_onboarding_done-Pref) — auch für v1.0–v1.3-Upgrader, die das Feature so entdecken. - Settings-Spiegel: Abschnitt "Erinnerungen" mit demselben Toggle +
Duplikat-Hinweis. Einschalten fordert
POST_NOTIFICATIONSkontextuell an, wenn sie fehlt. - Tap öffnet das Event-Detail: Notification-Intent trägt
eventId/begin/end;
MainActivitywirdsingleTop, reicht den Key als Compose-State anCalendarHostdurch (gleiches LongArray-Key-Muster wie der Detail-Overlay selbst). - Ein Kanal, einfache Inhalte: Kanal "Erinnerungen"
(
IMPORTANCE_HIGH), pro Alert eine Notification (Tag = Alert-Id): Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne (ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein Vollbild-Alarm.
Tasks
Manifest / Resourcen:
POST_NOTIFICATIONSins Manifest; Receiver.reminders.EventReminderReceiverexported="true", Intent-FilterEVENT_REMINDER+data scheme=content host=com.android.calendar;MainActivity→launchMode="singleTop"- Monochromes Notification-Icon
drawable/ic_notification.xml - Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
Prefs:
SettingsPrefs.remindersEnabled(default true) + Setter;reminderOnboardingDone(default false) + Setter;SettingsPrefsTest
Data layer (data/reminders/):
ReminderAlert-Modell (indata/reminders/, nicht domain — Alerts erreichen nie einen Screen): alertId, eventId, begin/end als Millis, title, location, isAllDayReminderAlertStore(Interface) +AndroidReminderAlertStore:dueAlerts(nowMillis)=CalendarAlertsmitSTATE_SCHEDULED AND ALARM_TIME <= now;markFired(ids, nowMillis)setzt STATE/RECEIVED_TIME/NOTIFY_TIME,SecurityException→ Log (Write-Recht optional)ReminderNotifier: Kanal lazy anlegen, eine Notification pro Alert (Tag = alertId,setOnlyAlertOnce, autoCancel,when= begin, Category EVENT), Content-PendingIntent aufMainActivitymit eventId/begin/end- Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
Receiver:
EventReminderReceiver(@AndroidEntryPoint): Action prüfen,goAsync(); raus, wenn Pref aus, READ_CALENDAR fehlt oder Notifications systemseitig geblockt; sonst posten →markFired
UI:
- Onboarding-Shell aus
PermissionScreenextrahieren (OnboardingScaffold+ BenefitRow, intern wiederverwendet) NotificationOnboardingScreen+ ViewModel: Benefit-Rows (verpasst nichts / Duplikat-Warnung), Primär-Button fordertPOST_NOTIFICATIONS(API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide setzenreminder_onboarding_doneRootScreen: Kalender-Gate → Reminder-Schritt (einmalig) →CalendarHostCalendarHost: externer Detail-Key (Notification-Tap) wird wie ein Event-Tap konsumiert;MainActivityparst Intent (onCreate + onNewIntent) in Compose-State- Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem Permission-Request beim Einschalten) + Duplikat-Hinweistext
Abschluss:
./gradlew lint test assembleDebuggrün- CHANGELOG (
[Unreleased]), ROADMAP-Status; kein Tag/Release vor On-Device-Review