Notifications in Android #288

Closed
opened 2026-10-17 08:53:45 +02:00 by kfickel · 2 comments
Owner

Add notifications that pop up whenever a reminder is due, along with a "DONE" quick action.

Add notifications that pop up whenever a reminder is due, along with a "DONE" quick action.
Collaborator

Plan

The Android app posts a notification when a reminder command (R29) becomes due, with a Done action that marks it as done exactly as the banner in the app does. This needs one new function in the core: when the next reminder becomes due. Everything else is on the Android side. The biggest change there is moving the stream and the repository out of the StreamViewModel into an object owned by the application, so a background worker can share them.

Decisions (clarified with the user)

  • Scheduling: at the next due moment, computed by the core. A WorkManager one-time job is scheduled for that moment, with a periodic job every few hours as a fallback. No exact-alarm permission, so in Doze a notification can come a few minutes late.
  • Pull first: the background check pulls the repository first if the network is up, so a reminder already done on the desktop does not notify. If the pull fails, it checks the local clone anyway.
  • Done: behaves as in the app. It appends the line, commits if commit every save is on and pushes if push after commit is on, without opening the app.
  • Repeat: one notification per reminder command. It alerts when the reminder becomes due (or its since changes) and again once a day for as long as it stays due. It is withdrawn once the reminder is no longer due.

1. Core: the next due moment

crates/streamd-core/src/commands/reminder/due.rs

  • next_due<S: Borrow<LocalizedShard>>(commands: &IndexMap<String, CommandDefinition>, shards: &[S], now, tz) -> Option<DateTime<Utc>> returns the earliest moment after now at which a reminder that is not due at now becomes due:
    • Trigger::Days(n): start_of_day(max(last, start) + n, tz) (localize::start_of_day, DST-safe). It is None if the reminder is already due (this includes the case of no last match and no start).
    • Trigger::Since(recurrence): the end of recurrence.span(start, now, tz), i.e. the next occurrence after now. It is None if the reminder is due or the recurrence ends.
    • The earliest of all reminder commands. Reuse expected_moments and finding. Do not duplicate the rules of due_after_days / due_since.
  • Tests next to the existing ones: days with and without start and with a future start; since daily, timed (BYHOUR=0,12) and across a DST change in Europe/Berlin; an already-due reminder contributes nothing; the earliest of several wins.

crates/streamd-core/src/api.rs: next_reminder_due(project, stream, now) -> Option<DateTime<Utc>>, with a test on a MemoryStore.

2. Bindings

crates/streamd-ffi

  • Project.next_reminder_due(stream, now) -> Option<Timestamp> in project.rs, with a test.
  • DueReminder (records.rs) gets text, the description or else the key (reminder::DueReminder.text), so the notification can use it as its title. summary stays.

3. Android: one stream per application

Today the RepositoryConnection and the StreamSession belong to the StreamViewModel. A worker would otherwise need a second RepositoryConnection on the same clone, with its own lock, which risks racing JGit.

  • New StreamdApplication : Application (android:name in the manifest) holds a new class Streamd (Streamd.kt). Streamd owns the RepositoryConnection, the StreamSession flow (made anew whenever the Connection changes, as now) and the PinStore, in an application CoroutineScope. It also has suspend fun ready(): StreamSession?, which starts the connection and waits for a loaded stream; the workers use it.
  • StreamViewModel takes everything from (application as StreamdApplication).streamd and keeps only what belongs to the screens. Its behaviour does not change. Because the session is shared, the app shows a done marked from a notification without reloading.

4. Android: notifications

New package notify/. Nothing outside it names NotificationManager or WorkManager, apart from the manifest and StreamdApplication.

  • Dependency: androidx.work:work-runtime-ktx in libs.versions.toml and app/build.gradle.kts. Write android/deps.json again with the update script and commit it.
  • Manifest: POST_NOTIFICATIONS (minSdk is 33, so it is a runtime permission). No boot receiver is needed, since WorkManager keeps its jobs across a reboot.
  • ReminderWorker (CoroutineWorker), unique work reminders:
    1. streamd.ready(); if no repository is connected, do nothing.
    2. If the network is up (checked with ConnectivityManager), repository.pull() and then session.reload(). Ignore a failure.
    3. session.reminders(now) gives what is due; notifications.show(due, now) (below).
    4. Schedule the next run at the earliest of session.nextDue(now) (bindings) and, if anything is still due, the next day's re-alert time. This is a unique OneTimeWorkRequest with ExistingWorkPolicy.REPLACE and an initialDelay.
  • Fallback: a PeriodicWorkRequest of 3 hours (unique, KEEP), enqueued in StreamdApplication.onCreate.
  • Rescheduling while the app runs: whenever the StreamState becomes another Ready (a save, a pull, a done, a changed configuration), Streamd runs a cheap check without a pull. It withdraws what is no longer due, posts what became due and reschedules. This keeps notifications in step with edits made in the app.
  • ReminderNotifications:
    • One channel, reminders ("Reminders", default importance).
    • One notification per reminder command, with a stable id from its key and a tag of key.
    • The title is text and the body is the summary. Tapping it opens MainActivity on the Stream tab, where the banner is.
    • Done is an action only where canMarkDone.
    • Withdraw the notifications of reminders that are no longer due.
  • Re-alert policy: keep it pure, in ReminderAlerts.kt with unit tests. It is given the due reminders, the record of what was alerted (key → since, local date of the last alert, kept in a small DataStore alerts), now and the zone. It returns what to post with sound, what to update silently (setOnlyAlertOnce), what to cancel and the new record.
    • Alert when a key is new or its since changed.
    • Alert again on a later local day, at the first check from 08:00 on (a constant), so nothing rings at midnight.
    • Update silently otherwise.
    • Drop the record of a key that is no longer due.
    • A notification the user swiped away is posted again only by the next alert (the next day or a new since), not by every check.
  • Done action: a PendingIntent to a BroadcastReceiver (ReminderDoneReceiver) that enqueues an expedited DoneWorker with the key. A receiver must not do the work itself. The DoneWorker:
    1. streamd.ready() and session.reminderDone(key, now).
    2. On MarkedDone.Done(id): repository.written(id), which commits and pushes according to the settings, the same path StreamViewModel.markDone takes. Cancel the notification and drop its record.
    3. On MarkedDone.Failed(reason): replace the notification with one saying the reason, keeping Done.
    4. Run the check of step 3 so the next run is scheduled again.
  • Permission: ask for POST_NOTIFICATIONS once, the first time the stream is Ready with at least one reminder command (CommandKind of the summaries). Use rememberLauncherForActivityResult in App.kt, and remember in the DataStore that it was asked. If it was denied, nothing more is asked and the workers still run; posting is skipped when NotificationManagerCompat.areNotificationsEnabled() is false.

5. Tests

  • Core and bindings tests as above, run with cargo test and nix flake check.
  • Android unit tests (JUnit 4) for ReminderAlerts: a new key alerts, the same since the same day updates silently, the next day before 08:00 stays silent, the next day after 08:00 alerts again, a changed since alerts, a key no longer due is cancelled and dropped.
  • The StreamViewModel refactor must leave the screenshot tests unchanged. The screens do not change, since only the content composables are drawn.
  • By hand on the emulator (nix run .#emulator): a timed reminder since = "FREQ=MINUTELY;INTERVAL=5" notifies, Done writes, commits and pushes and the notification goes away, the app shows the done without a reload, a denied permission posts nothing, and it still works after a reboot.

6. Documentation

  • REQUIREMENTS.md:
    • R29 gets a paragraph on the next due moment (what next_due returns).
    • The app requirement (R35 / the app section) gets the notifications: when they are posted, the re-alert policy, Done, pull first, the permission.
  • README.md: the notifications of the app.
  • AGENTS.md:
    • Streamd / StreamdApplication and that the view model takes the stream from it.
    • The notify/ package, and that nothing outside it names WorkManager or NotificationManager.
    • next_due in due.rs and next_reminder_due in the facade list.
    • WorkManager added to the list of dependencies deps.json was written again for.

Commits (suggested)

  1. feat(reminder): the moment the next reminder becomes due
  2. feat(ffi): nextReminderDue and the text of a due reminder
  3. refactor(android): the stream and the repository belong to the application
  4. feat(android): notifications of due reminders with a Done action
  5. docs: notifications of reminders on Android
## Plan The Android app posts a notification when a reminder command (R29) becomes due, with a **Done** action that marks it as done exactly as the banner in the app does. This needs one new function in the core: when the next reminder becomes due. Everything else is on the Android side. The biggest change there is moving the stream and the repository out of the `StreamViewModel` into an object owned by the application, so a background worker can share them. ### Decisions (clarified with the user) - **Scheduling:** at the next due moment, computed by the core. A WorkManager one-time job is scheduled for that moment, with a periodic job every few hours as a fallback. No exact-alarm permission, so in Doze a notification can come a few minutes late. - **Pull first:** the background check pulls the repository first if the network is up, so a reminder already done on the desktop does not notify. If the pull fails, it checks the local clone anyway. - **Done:** behaves as in the app. It appends the line, commits if *commit every save* is on and pushes if *push after commit* is on, without opening the app. - **Repeat:** one notification per reminder command. It alerts when the reminder becomes due (or its `since` changes) and again once a day for as long as it stays due. It is withdrawn once the reminder is no longer due. ### 1. Core: the next due moment `crates/streamd-core/src/commands/reminder/due.rs` - `next_due<S: Borrow<LocalizedShard>>(commands: &IndexMap<String, CommandDefinition>, shards: &[S], now, tz) -> Option<DateTime<Utc>>` returns the earliest moment after `now` at which a reminder that is **not** due at `now` becomes due: - `Trigger::Days(n)`: `start_of_day(max(last, start) + n, tz)` (`localize::start_of_day`, DST-safe). It is `None` if the reminder is already due (this includes the case of no last match and no `start`). - `Trigger::Since(recurrence)`: the end of `recurrence.span(start, now, tz)`, i.e. the next occurrence after `now`. It is `None` if the reminder is due or the recurrence ends. - The earliest of all reminder commands. Reuse `expected_moments` and `finding`. Do not duplicate the rules of `due_after_days` / `due_since`. - Tests next to the existing ones: days with and without `start` and with a future `start`; `since` daily, timed (`BYHOUR=0,12`) and across a DST change in `Europe/Berlin`; an already-due reminder contributes nothing; the earliest of several wins. `crates/streamd-core/src/api.rs`: `next_reminder_due(project, stream, now) -> Option<DateTime<Utc>>`, with a test on a `MemoryStore`. ### 2. Bindings `crates/streamd-ffi` - `Project.next_reminder_due(stream, now) -> Option<Timestamp>` in `project.rs`, with a test. - `DueReminder` (`records.rs`) gets `text`, the description or else the key (`reminder::DueReminder.text`), so the notification can use it as its title. `summary` stays. ### 3. Android: one stream per application Today the `RepositoryConnection` and the `StreamSession` belong to the `StreamViewModel`. A worker would otherwise need a second `RepositoryConnection` on the same clone, with its own lock, which risks racing JGit. - New `StreamdApplication : Application` (`android:name` in the manifest) holds a new class `Streamd` (`Streamd.kt`). `Streamd` owns the `RepositoryConnection`, the `StreamSession` flow (made anew whenever the `Connection` changes, as now) and the `PinStore`, in an application `CoroutineScope`. It also has `suspend fun ready(): StreamSession?`, which starts the connection and waits for a loaded stream; the workers use it. - `StreamViewModel` takes everything from `(application as StreamdApplication).streamd` and keeps only what belongs to the screens. Its behaviour does not change. Because the session is shared, the app shows a done marked from a notification without reloading. ### 4. Android: notifications New package `notify/`. Nothing outside it names `NotificationManager` or WorkManager, apart from the manifest and `StreamdApplication`. - **Dependency:** `androidx.work:work-runtime-ktx` in `libs.versions.toml` and `app/build.gradle.kts`. **Write `android/deps.json` again** with the update script and commit it. - **Manifest:** `POST_NOTIFICATIONS` (minSdk is 33, so it is a runtime permission). No boot receiver is needed, since WorkManager keeps its jobs across a reboot. - **`ReminderWorker`** (`CoroutineWorker`), unique work `reminders`: 1. `streamd.ready()`; if no repository is connected, do nothing. 2. If the network is up (checked with `ConnectivityManager`), `repository.pull()` and then `session.reload()`. Ignore a failure. 3. `session.reminders(now)` gives what is due; `notifications.show(due, now)` (below). 4. Schedule the next run at the earliest of `session.nextDue(now)` (bindings) and, if anything is still due, the next day's re-alert time. This is a unique `OneTimeWorkRequest` with `ExistingWorkPolicy.REPLACE` and an `initialDelay`. - **Fallback:** a `PeriodicWorkRequest` of 3 hours (unique, `KEEP`), enqueued in `StreamdApplication.onCreate`. - **Rescheduling while the app runs:** whenever the `StreamState` becomes another `Ready` (a save, a pull, a done, a changed configuration), `Streamd` runs a cheap check without a pull. It withdraws what is no longer due, posts what became due and reschedules. This keeps notifications in step with edits made in the app. - **`ReminderNotifications`:** - One channel, `reminders` ("Reminders", default importance). - One notification per reminder command, with a stable id from its `key` and a tag of `key`. - The title is `text` and the body is the `summary`. Tapping it opens `MainActivity` on the Stream tab, where the banner is. - **Done** is an action only where `canMarkDone`. - Withdraw the notifications of reminders that are no longer due. - **Re-alert policy:** keep it pure, in `ReminderAlerts.kt` with unit tests. It is given the due reminders, the record of what was alerted (`key → since, local date of the last alert`, kept in a small DataStore `alerts`), `now` and the zone. It returns what to post with sound, what to update silently (`setOnlyAlertOnce`), what to cancel and the new record. - Alert when a key is new or its `since` changed. - Alert again on a later local day, at the first check from **08:00** on (a constant), so nothing rings at midnight. - Update silently otherwise. - Drop the record of a key that is no longer due. - A notification the user swiped away is posted again only by the next alert (the next day or a new `since`), not by every check. - **Done action:** a `PendingIntent` to a `BroadcastReceiver` (`ReminderDoneReceiver`) that enqueues an expedited `DoneWorker` with the `key`. A receiver must not do the work itself. The `DoneWorker`: 1. `streamd.ready()` and `session.reminderDone(key, now)`. 2. On `MarkedDone.Done(id)`: `repository.written(id)`, which commits and pushes according to the settings, the same path `StreamViewModel.markDone` takes. Cancel the notification and drop its record. 3. On `MarkedDone.Failed(reason)`: replace the notification with one saying the reason, keeping **Done**. 4. Run the check of step 3 so the next run is scheduled again. - **Permission:** ask for `POST_NOTIFICATIONS` once, the first time the stream is `Ready` with at least one reminder command (`CommandKind` of the summaries). Use `rememberLauncherForActivityResult` in `App.kt`, and remember in the DataStore that it was asked. If it was denied, nothing more is asked and the workers still run; posting is skipped when `NotificationManagerCompat.areNotificationsEnabled()` is false. ### 5. Tests - Core and bindings tests as above, run with `cargo test` and `nix flake check`. - Android unit tests (JUnit 4) for `ReminderAlerts`: a new key alerts, the same `since` the same day updates silently, the next day before 08:00 stays silent, the next day after 08:00 alerts again, a changed `since` alerts, a key no longer due is cancelled and dropped. - The `StreamViewModel` refactor must leave the screenshot tests unchanged. The screens do not change, since only the content composables are drawn. - By hand on the emulator (`nix run .#emulator`): a timed reminder `since = "FREQ=MINUTELY;INTERVAL=5"` notifies, Done writes, commits and pushes and the notification goes away, the app shows the done without a reload, a denied permission posts nothing, and it still works after a reboot. ### 6. Documentation - `REQUIREMENTS.md`: - R29 gets a paragraph on the next due moment (what `next_due` returns). - The app requirement (R35 / the app section) gets the notifications: when they are posted, the re-alert policy, Done, pull first, the permission. - `README.md`: the notifications of the app. - `AGENTS.md`: - `Streamd` / `StreamdApplication` and that the view model takes the stream from it. - The `notify/` package, and that nothing outside it names WorkManager or `NotificationManager`. - `next_due` in `due.rs` and `next_reminder_due` in the facade list. - WorkManager added to the list of dependencies `deps.json` was written again for. ### Commits (suggested) 1. `feat(reminder): the moment the next reminder becomes due` 2. `feat(ffi): nextReminderDue and the text of a due reminder` 3. `refactor(android): the stream and the repository belong to the application` 4. `feat(android): notifications of due reminders with a Done action` 5. `docs: notifications of reminders on Android`
Collaborator

Implementation report

Implemented in PR #290 (branch 288_android-reminder-notifications), as five commits that follow the plan.

  • Duration: about 25 minutes.
  • Tokens: about 210k.

Findings

  • The core needed one function, next_due. It is built on the existing finding / expected_moments: a reminder that is due now contributes nothing, a days rule comes due at start_of_day(max(last, start) + N), and a since rule comes due at the end of Recurrence::span. The DST test (Berlin, 2026-10-25) confirmed the wall-clock behaviour.
  • The app's Reminder gained text and since. The default values keep the existing tests and screenshot fixtures unchanged.
  • Moving the stream to the application (Streamd) was a clean cut. The view model's init now only calls streamd.start(), which runs once per process, followed by the pull-on-open. The screenshot tests did not change.
  • Gotcha: the update script for deps.json builds from the flake, which only sees files Git knows. The new Kotlin files have to be staged (git add) first, or the script fails with "unresolved reference".
  • WorkManager detail: the scheduled check reschedules itself with REPLACE on its own unique work, so WorkManager cancels the running worker. This happens after the check has finished, so nothing is lost, but the run shows as cancelled.
  • If notifications are disabled, the alert record is cleared instead of saved. Once the user allows notifications, what is due rings at the next check instead of waiting for the next day.
  • On the emulator, the app starts, the worker runs and the fallback job is scheduled.
  • Still to test by hand: a notification for a real reminder, Done committing and pushing, a denied permission, and behaviour after a reboot. These need a connected repository on the emulator.
  • The README still mentioned the Review of a reminder, which was removed in 288fe85; that sentence has been dropped.
## Implementation report Implemented in PR #290 (branch `288_android-reminder-notifications`), as five commits that follow the plan. - **Duration:** about 25 minutes. - **Tokens:** about 210k. ### Findings - The core needed one function, `next_due`. It is built on the existing `finding` / `expected_moments`: a reminder that is due now contributes nothing, a `days` rule comes due at `start_of_day(max(last, start) + N)`, and a `since` rule comes due at the end of `Recurrence::span`. The DST test (Berlin, 2026-10-25) confirmed the wall-clock behaviour. - The app's `Reminder` gained `text` and `since`. The default values keep the existing tests and screenshot fixtures unchanged. - Moving the stream to the application (`Streamd`) was a clean cut. The view model's `init` now only calls `streamd.start()`, which runs once per process, followed by the pull-on-open. The screenshot tests did not change. - **Gotcha:** the update script for `deps.json` builds from the flake, which only sees files Git knows. The new Kotlin files have to be staged (`git add`) first, or the script fails with "unresolved reference". - **WorkManager detail:** the scheduled check reschedules itself with `REPLACE` on its own unique work, so WorkManager cancels the running worker. This happens after the check has finished, so nothing is lost, but the run shows as cancelled. - If notifications are disabled, the alert record is cleared instead of saved. Once the user allows notifications, what is due rings at the next check instead of waiting for the next day. - On the emulator, the app starts, the worker runs and the fallback job is scheduled. - **Still to test by hand:** a notification for a real reminder, Done committing and pushing, a denied permission, and behaviour after a reboot. These need a connected repository on the emulator. - The README still mentioned the **Review** of a reminder, which was removed in 288fe85; that sentence has been dropped.
Sign in to join this conversation.
No labels
android
cli
epic
planned
tui
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
kfickel/streamd#288
No description provided.