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).
2. Request consent at runtime
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::Notificationsfirst (step 2);post()returningOkis not proof of delivery. - On Android,
Importance::Defaultputs the notification in the shade only. UseImportance::HighorUrgentfor 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 itsdidReceivedelivers taps. If you install your ownUNUserNotificationCenterDelegate, you take that job over. day launchruns an unbundled binary, andUNUserNotificationCenterdoes not exist without a bundle identifier; the crate reportsUnsupportedrather than crashing. Runday pack -p macos-appkitand launch the.appto 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.