Local notifications

Local notifications let an app deliver reminders and status updates through the operating system. day-part-local-notify supports immediate and scheduled delivery, including reminders due after the app has closed. Posting a notification requires permission and a configured channel:

Works on: iOS, macOS, and Android. Support is selected by target_os and applies to every backend on those operating systems. Linux, Windows, HarmonyOS, and the web compile the same code and report NotifyError::Unsupported; gate the UI with capabilities() (step 6). The full design and the per-platform details are in the notify reference.

1. Declare the permission

Add day-part-local-notify and day-part-permissions to your app’s Cargo dependencies, using the same Day revision as the rest of the project.

Declare notification access in Day.toml:

[permissions]
notifications = true      # needs no reason string on any platform

Most permissions take a user-facing reason as their value, the sentence the OS shows in its prompt. Notifications are the one portable permission that needs none, so true is enough. day build generates the platform entries from this (POST_NOTIFICATIONS on Android 13+; Apple needs no Info.plist key for local notifications).

Declaring the permission does not request it. An app must still request Permission::Notifications, and on Apple the failure mode for skipping this is silent: the system accepts every post and drops it with no error. The Apple arm cannot warn you either: its settings accessor is async-only, so post() never returns PermissionDenied there.

use day_part_permissions::{Permission, Status, request, status};

if status(Permission::Notifications) != Status::Granted {
    let set = granted.setter();       // Signal<bool> in your UI state
    request(Permission::Notifications, move |s| {
        set.set(s == Status::Granted);
    });
}

The callback may run on another thread, so use a Setter to update UI state. Ask when the user enables a feature that needs notifications. The permissions guide explains how to handle a denial and offer a link to system settings.

3. Post one now

Every notification posts on a channel. Channels exist because Android has a per-channel settings model the user owns; on Apple a channel still groups notifications and carries their importance and sound. Register the channel once, before posting to it; registration is idempotent:

use day_part_local_notify::{Channel, Importance, Notification};

Channel::new("timers", Importance::High).sound(true).register();

let id = Notification::new("Timer done")
    .body("Your 5 minute timer finished.")
    .channel("timers")
    .post()?;

Choose the importance for the behavior you want. Importance::High or above shows a heads-up banner on Android; Default files the notification into the shade with no banner, which reads as “the button did nothing”. On Android the importance is immutable after first registration (the user owns the channel from then on), which is why it’s a required argument at Channel::new rather than a setter.

post() returns a NotifId. Posting again with the same id (via .id(id)) updates the existing notification in place instead of stacking a second one.

4. Schedule and cancel

Trigger has three variants: Now (the default), In(Duration), and At(SystemTime), the alarm-clock form that fires at an absolute instant:

use day_part_local_notify::{Trigger, cancel};
use std::time::{Duration, SystemTime};

let id = Notification::new("Stand up")
    .channel("timers")
    .trigger(Trigger::In(Duration::from_secs(20 * 60)))
    .post()?;

cancel(id);                           // or cancel_all()

let wake = SystemTime::now() + Duration::from_secs(8 * 3600);
Notification::new("Good morning")
    .channel("alarms")
    .trigger(Trigger::At(wake))
    .post()?;

At takes an instant, not a civil time: resolve “06:30 tomorrow” to a SystemTime yourself (a tzdb crate such as jiff has the zone arithmetic; Day-Time’s timezone module shows the pattern), and re-derive on each foreground if you’re tracking a local-time target across DST changes. An instant already in the past fires immediately.

Where capabilities().schedule_while_dead is true, the OS holds the trigger and fires it even if the app has exited. On Apple the system holds the schedule itself. Android has no notification scheduler, so the part sets an AlarmManager alarm that wakes a receiver; a reboot clears every alarm, and the part’s boot receiver re-arms schedules from persisted data. When Android withholds the exact-alarm grant, capabilities().schedule_exact is false and the notification still arrives, possibly late in Doze. Declaring android.permission.USE_EXACT_ALARM in your app’s [package.metadata.day.android] gets the install-time exact-alarm grant (Play restricts it to clock and calendar apps, so the part can’t declare it for you), and scheduling on a channel registered with Importance::Urgent goes through AlarmManager.setAlarmClock, the Doze-exempt path that shows the status-bar alarm icon.

A scheduled notification may fire in a process with no Day tree alive, so its content is snapshotted at post time as plain strings: a Notification cannot bind a signal or a closure.

5. Route the tap

.route("clock/timer") names the Day route a tap navigates to, the same route strings the navigation rail and deep links use. The tap is delivered through day_core::request_route, which handles both cases: a warm tap navigates the running app, and a tap that cold-starts the process is buffered and replayed once routing is live, so the app opens on the target screen. capabilities().tap_route says whether the running platform delivers taps.

6. Gate the UI

Query what the platform can do instead of branching on target names:

let caps = day_part_local_notify::capabilities();
caps.post                 // notifications work at all (shorthand: is_supported())
caps.schedule_while_dead  // the OS holds a schedule across app exit
caps.channels             // a user-facing channel model (Android)
caps.badge                // app-icon badge counts
caps.icon                 // custom small icons (Android)
caps.tap_route            // taps route into the app
caps.schedule_exact       // scheduled fires are on time

On unsupported targets every field is false and post() returns NotifyError::Unsupported, so a notifications section can hide itself.

Pitfalls

  • Without granted consent, Apple accepts the post and drops it silently. Request Permission::Notifications first (step 2); post() returning Ok is not proof of delivery.
  • On Android, Importance::Default puts the notification in the shade only. Use Importance::High or Urgent for a heads-up banner.
  • iOS suppresses a notification posted while the app is frontmost unless a delegate opts in from willPresent. The crate ships that delegate; it shows the banner, the list entry, and the channel’s sound, and its didReceive delivers taps. If you install your own UNUserNotificationCenterDelegate, you take that job over.
  • day launch runs an unbundled binary, and UNUserNotificationCenter does not exist without a bundle identifier; the crate reports Unsupported rather than crashing. Run day pack -p macos-appkit and launch the .app to see real notifications in the dev loop.

Reference

notify — the full API, the per-platform capability matrix, scheduling internals, and plans for platforms not yet supported. permissions covers the consent half, and Permissions covers the app configuration and runtime requests.