diff --git a/Cargo.lock b/Cargo.lock index 8a581d4..3c9b187 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -648,6 +648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", + "objc2-exception-helper", ] [[package]] @@ -702,6 +703,15 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -714,6 +724,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-intents" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80fac082e6de12282b24ea745e9707cd374363c7ae0b2f813bf1813cfc289325" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-local-authentication" version = "0.3.2" @@ -760,6 +781,18 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -1048,6 +1081,26 @@ dependencies = [ "windows", ] +[[package]] +name = "robius-notifications" +version = "0.3.1" +dependencies = [ + "android-build", + "block2", + "cfg-if", + "dispatch2", + "jni", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-intents", + "objc2-ui-kit", + "objc2-user-notifications", + "robius-android-env", + "windows", + "zbus", +] + [[package]] name = "robius-open" version = "0.3.1" @@ -1216,6 +1269,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -1293,11 +1356,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4" dependencies = [ "backtrace", + "bytes", "io-uring", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "slab", + "socket2", + "tracing", + "windows-sys 0.52.0", ] [[package]] @@ -1905,6 +1973,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 867d4b7..1eb7753 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,11 +69,13 @@ objc2-app-kit = { version = "0.3.2", default-features = false, objc2-authentication-services = { version = "0.3.2", default-features = false, features = ["std"] } objc2-core-location = { version = "0.3.2", default-features = false, features = ["std"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["std"] } +objc2-intents = { version = "0.3.2", default-features = false, features = ["std"] } objc2-local-authentication = { version = "0.3.2", default-features = false, features = ["std"] } objc2-photos = { version = "0.3.2", default-features = false, features = ["std"] } objc2-photos-ui = { version = "0.3.2", default-features = false, features = ["std"] } objc2-ui-kit = { version = "0.3.2", default-features = false, features = ["std"] } objc2-uniform-type-identifiers = { version = "0.3.2", default-features = false, features = ["std"] } +objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["std"] } ## Linux-specific dependencies used in multiple crates in the workspace. diff --git a/crates/authentication/Cargo.toml b/crates/authentication/Cargo.toml index cf2b074..ff444f6 100644 --- a/crates/authentication/Cargo.toml +++ b/crates/authentication/Cargo.toml @@ -52,7 +52,7 @@ objc2-foundation = { workspace = true, features = ["NSError", "NSString"] } # optional = true [target.'cfg(target_os = "linux")'.dependencies] -zbus.workspace = true +zbus = { workspace = true, features = ["async-io", "blocking-api"] } zbus_polkit = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/notifications/Cargo.toml b/crates/notifications/Cargo.toml new file mode 100644 index 0000000..46fa995 --- /dev/null +++ b/crates/notifications/Cargo.toml @@ -0,0 +1,147 @@ +[package] +name = "robius-notifications" +version.workspace = true +edition.workspace = true +rust-version = "1.77.0" +authors = [ + "Kevin Boos ", + "Project Robius Maintainers", +] +description = "Rust abstractions for showing system notifications and handling user interactions with them, across multiple platforms" +documentation = "https://docs.rs/robius-notifications" +homepage.workspace = true +keywords = ["robius", "notification", "native", "alert", "banner"] +categories.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" + + +## Note: ideally this would be only included when we're building for Android, +## but Cargo doesn't support target-specific build scripts yet. +## See: and +## . +[build-dependencies] +android-build.workspace = true + + +[features] +default = ["zbus-async-io"] + +## Selects zbus's built-in async-io executor for the Linux backend. +## The default; irrelevant on non-Linux platforms. +zbus-async-io = ["zbus/async-io"] + +## Backs the Linux backend's D-Bus I/O with tokio instead of zbus's bundled +## async-io executor. For an app that already depends on tokio, enabling this +## (with `default-features = false`) drops the whole async-io dependency stack +## from Linux builds. Irrelevant on non-Linux platforms. +## +## Note: zbus still drives this on its own internal runtime rather than your +## app's, so this is purely a dependency-graph choice, not executor sharing; +## either way the D-Bus work happens on a thread this crate owns, so calls +## are safe from anywhere (including inside a tokio runtime). +## +## With `default-features = false`, exactly one of `zbus-async-io` or `tokio` +## must be enabled, or the Linux backend won't compile. +tokio = ["zbus/tokio"] + +## Enables Apple communication notifications for conversation-style notifications, +## where it shows sender/group avatars, Siri & Focus intents, etc. on iOS 15+/macOS 12+. +## +## Requires your app to do the following: +## * Declare the `com.apple.developer.usernotifications.communication` entitlement, +## * In its Info.plist, include `INSendMessageIntent` in the `NSUserActivityTypes` array. +## +## If you don't specify that, they'll just show up as normal notifications. +apple-communication = ["dep:objc2-intents", "objc2-foundation/NSData"] + + +[dependencies] +cfg-if.workspace = true + + +[target.'cfg(target_os = "android")'.dependencies] +jni.workspace = true +robius-android-env.workspace = true + + +[target.'cfg(target_vendor = "apple")'.dependencies] +block2.workspace = true +## "exception" lets us survive UNUserNotificationCenter throwing in an +## app bundle the system hasn't registered, instead of aborting the process. +objc2 = { workspace = true, features = ["exception"] } +objc2-intents = { workspace = true, optional = true, features = [ + "INImage", + "INIntent", + "INIntentResponse", + "INInteraction", + "INOutgoingMessageType", + "INPerson", + "INPersonHandle", + "INSendMessageAttachment", + "INSendMessageIntent", + "INSpeakableString", + "block2", +] } +objc2-foundation = { workspace = true, features = [ + "NSArray", + "NSBundle", + "NSDictionary", + "NSEnumerator", + "NSError", + "NSObject", + "NSSet", + "NSString", + "NSURL", + "NSValue", +] } +objc2-user-notifications = { workspace = true, features = [ + "UNError", + "UNNotification", + "UNNotificationAction", + "UNNotificationAttachment", + "UNNotificationCategory", + "UNNotificationContent", + "UNNotificationRequest", + "UNNotificationResponse", + "UNNotificationSettings", + "UNNotificationSound", + "UNNotificationTrigger", + "UNUserNotificationCenter", + "block2", +] } + + +[target.'cfg(target_os = "ios")'.dependencies] +dispatch2 = { workspace = true, features = ["objc2"] } +objc2-ui-kit = { workspace = true, features = [ + "UIApplication", + "UIResponder", + "block2", +] } + + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = { workspace = true, features = [ + "NSWorkspace", +] } + + +[target.'cfg(target_os = "linux")'.dependencies] +## The executor backend comes from this crate's `zbus-async-io` (default) +## or `tokio` feature. +zbus = { workspace = true, features = ["blocking-api"] } + + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { workspace = true, features = [ + "Data_Xml_Dom", + "Foundation", + "Foundation_Collections", + "System", + "UI_Notifications", + "Win32_Foundation", + "Win32_System_Com", + "Win32_UI_Shell", +] } diff --git a/crates/notifications/README.md b/crates/notifications/README.md new file mode 100644 index 0000000..283e29e --- /dev/null +++ b/crates/notifications/README.md @@ -0,0 +1,413 @@ +# robius-notifications + +`robius-notifications` provides a Rust builder API for showing system +notifications from an app, and for handling how the user interacts with them: +tapping the notification, pressing one of its action buttons, submitting a +quick reply, or dismissing it. + +```rust,no_run +use robius_notifications::{Action, Interaction, InteractionKind, Notification}; + +// Do this once, as early as possible at app startup. +robius_notifications::set_interaction_handler(|interaction: Interaction| { + match interaction.kind { + InteractionKind::Activated => { + println!("notification {:?} was tapped", interaction.notification_id); + } + InteractionKind::Reply { text, .. } => println!("user replied: {text}"), + _ => {} + } +})?; + +// Ask once at startup; platforms without a permission prompt report granted immediately. +robius_notifications::request_permission(|granted| { + if !matches!(granted, Ok(true)) { + return; + } + Notification::new() + .set_id("new-message") + .set_title("New message") + .set_body("Hello from Robius!") + .add_action(Action::button("mark-read", "Mark as read")) + .add_action(Action::reply("reply", "Reply").set_placeholder("Type a reply…")) + .add_metadata("conversation", "robius") + .show() + .expect("failed to show notification"); +})?; +# Ok::<(), robius_notifications::Error>(()) +``` + +The metadata you attach with `add_metadata` comes back on every interaction, +so you can route it (e.g., which conversation to open) without keeping your +own bookkeeping. + +## Platform behavior + +- Android posts notifications via `NotificationManager` with notification + channels; actions use broadcast `PendingIntent`s and `RemoteInput` quick + replies. +- iOS and macOS use `UNUserNotificationCenter` from the UserNotifications + framework, with actions and quick replies via notification categories. +- Windows uses WinRT toast notifications (`ToastNotificationManager`), with + action buttons and quick-reply text boxes in the toast XML. +- Linux uses the standard `org.freedesktop.Notifications` D-Bus service, + which works on both X11 and Wayland. + +A successful `show()` means the notification was handed off to the OS, not +that it was displayed: the OS may still suppress it (Do-Not-Disturb, missing +permission, etc.). On Android, a missing notification permission is reported +synchronously as `Error::PermissionDenied`. + +## Terminology + +Notification APIs use overlapping words for different things, so here's how +this crate's names map onto each platform's: + +| This crate | What it is | Native name | +| -------------------------------- | ------------------------------------------------------------- | ----------- | +| `NotificationChannel` / `set_channel` | a user-manageable kind of notification | Android "notification channel", shown to users as a notification **"category"** in system settings | +| `NotificationChannel::set_group` | a titled **section of categories** in system settings (e.g., one per account) | Android "notification channel group" | +| `Notification::set_group` | visual stacking of related notifications on screen | iOS/macOS "thread identifier", Android "notification group" | +| `Conversation` / `set_conversation` | an ongoing chat with a person or group | Android "conversation" (the Conversations section + per-conversation settings), the conversation id in Apple's communication notifications | +| `Urgency` | how prominently a notification interrupts | Android channel "importance", Apple "interruption level", Linux "urgency" hint | + +One caution: iOS's `UNNotificationCategory` is *not* the "category" above — +it's Apple's action-set descriptor, which this crate manages internally when +you `add_action`; it never appears in this API. + +## Conversations + +Attach a `Conversation` to give messaging notifications the platform's +conversation treatment: + +```rust,no_run +use robius_notifications::{Conversation, Notification}; + +Notification::new() + .set_id("chat-42-message-7") + .set_title("Riley") + .set_body("See you at 10?") + .set_conversation(Conversation::new("chat-42", "Team chat").set_group_conversation(true)) + .show()?; +# Ok::<(), robius_notifications::Error>(()) +``` + +- **Android 11+**: the notification appears in the dedicated Conversations + section of the shade, styled as a message from the sender, and the user gets + per-conversation settings (priority, silent, bubble). Android 9/10 get the + message styling without the conversation section; Android 8 ignores it. +- **iOS/macOS**: the conversation id groups notifications like `set_group` + does (an explicit `set_group` wins). Enable the **`apple-communication`** + cargo feature to upgrade conversation notifications into Apple + communication notifications on iOS 15+/macOS 12+: the conversation icon + becomes the sender's (or the group's) avatar, and the donated intents feed + Siri suggestions and Focus. Note that Focus's "allowed people" breakthrough + matches against the user's *contacts*, which needs contact details + (phone/email) that `Conversation` doesn't currently carry — so the rendering + upgrade applies, but per-contact breakthrough doesn't yet. Requires the + entitlement and `Info.plist` entry listed under packaging requirements; + without them, the plain rendering is used. +- **Windows**: ignored. **Linux**: sent as the advisory `im.received` + category hint, which some daemons use for theming. + +## Progress, scheduling, and other modes + +Every option below is safe to use unconditionally: it maps to the native +feature where one exists, keeps each platform's default when unset, and is a +no-op where the platform has no such concept. + +```rust,no_run +use std::time::{Duration, SystemTime}; +use robius_notifications::{Notification, Progress}; + +// A download notification with a progress bar... +Notification::new() + .set_id("download-42") + .set_title("Downloading report.pdf") + .set_progress(Progress::Determinate { current: 0, total: 100 }) + .show()?; +// ...advanced quietly in place (no re-alert) as bytes arrive: +robius_notifications::update_progress( + "download-42", + Progress::Determinate { current: 30, total: 100 }, +)?; + +// A reminder shown 5 minutes from now: +Notification::new() + .set_title("Meeting in 5 minutes") + .set_scheduled_time(SystemTime::now() + Duration::from_secs(300)) + .show()?; +# Ok::<(), robius_notifications::Error>(()) +``` + +- **Progress** (`set_progress` + `update_progress`): real progress bars on + Android and Windows, updated in place without re-alerting (progress + notifications alert only when first shown). Linux daemons that support the + `value` hint show the percentage; iOS/macOS have no notification progress + concept (no-op). +- **Scheduling** (`set_scheduled_time`): on iOS/macOS/Windows the OS shows the + notification at the scheduled time, even if the app has exited (Windows + needs a registered AUMID for that, and interactions with a scheduled toast + aren't delivered in-process). Android and Linux use an in-process timer, so + the showing dies with the app. Past times show immediately; `cancel` also + cancels a pending scheduled showing. +- **Persistent** (`set_persistent`): Android "ongoing" (not swipeable away), + Windows reminder-style (stays on screen — Windows only honors this when the + toast has at least one action button), Linux never-auto-expires; + iOS/macOS have no equivalent (no-op). +- **Lock-screen privacy** (`set_lock_screen_visibility`): Android + public/private/secret; the other platforms manage lock-screen privacy at + the OS level (no-op). Unset = the user's own lock-screen setting decides. +- **Timestamps** (`set_timestamp`): Android shows the event time instead of + the post time; others always show delivery time (no-op). +- **Quiet permission** (`request_provisional_permission`): never prompts. + iOS/macOS grant provisional authorization (quiet delivery straight to + Notification Center); Android reports the standing state; Windows/Linux + behave like `request_permission`. +- **Do-Not-Disturb bypass** (`set_bypass_do_not_disturb` + + `set_uses_critical_alerts`): Apple critical alerts and Android + DnD-bypassing channels — both gated by the packaging requirements listed + above; the OS silently downgrades without them. Windows/Linux have no + override (no-op). +- **Active notifications** (`active_notification_ids`): reports which of the + app's notifications are still showing. Android and iOS/macOS report all of + them; Windows and Linux only those shown by this run of the app. +- **Group summaries**: Android only bundles `set_group` notifications when a + summary notification exists, so the crate posts and prunes one + automatically — no API needed. + +## Notification preferences + +The OS owns notification preferences, but apps can read them back, deep-link +into them, and (on Apple platforms) be linked back *from* them: + +- `notification_settings(scope, callback)` reports the user's current + settings for `SettingsScope::App`, one `Channel`, or one `Conversation`. + Android reports real per-channel/per-conversation state (urgency, sound, + badge, whether the user customized it, priority-conversation); iOS, macOS, + and Windows report app-level state for every scope; Linux only reports + whether a notification service is reachable. +- `open_notification_settings(scope)` opens the OS settings UI: Android down + to a single channel's or conversation's page, iOS the app's Settings page, + Windows the system notification settings, macOS best-effort. Linux returns + `Error::Unsupported`. +- `set_provides_notification_settings(true)` (call before + `request_permission`) tells iOS/macOS that the app has its own notification + settings screen; the OS then links to the app from its settings UI, and the + user choosing that link arrives at your interaction handler as + `InteractionKind::OpenSettings` — navigate to your settings screen when it + does. + +## Handling interactions + +Register your handler with `set_interaction_handler` **as early as possible** +during app startup. Interactions that arrive before a handler is set are +queued and delivered once one is set, but an interaction can only be delivered +at all if the app process learns about it: + +- **iOS/macOS**: interactions are delivered even when they launched the app, + via the notification center delegate. +- **Android**: tapping the notification body launches (or re-focuses) the + app's launcher activity, and the tap is delivered on startup. Action + presses, replies, and dismissals are delivered only while the app process + is running. +- **Windows and Linux**: interactions are delivered only while the app is + running; activating a notification after the app exits does not relaunch it. + +The handler may run on any thread (a platform callback thread, the main UI +thread, or a background thread), so use a channel or your UI toolkit's +equivalent (e.g., `Cx::post_action` in Makepad) to get interactions over to +your app logic. + +## Permission + +Call `request_permission` before showing notifications. + +- **Android 13+** shows the system prompt (see the manifest requirement + below); Android 12 and older report whether notifications are enabled. +- **iOS/macOS** show the system prompt once; afterwards it reports the user's + standing decision (changeable in system settings). +- **Windows** reports whether toasts are currently enabled for the app. +- **Linux** has no permission concept and always reports granted. + +## App packaging requirements + +Most of this crate works with zero app configuration, but some features need +an entry in your app's manifest, entitlements, or packaging. Everything that +does is listed here (and on the corresponding function's docs): + +| Requirement | Needed for | Where | +| ----------- | ---------- | ----- | +| `minSdk` 26 (Android 8.0) | the whole crate on Android | Android build config | +| `` | showing any notification on Android 13+ (`request_permission`) | Android manifest | +| `com.apple.developer.usernotifications.time-sensitive` | full `Urgency::Critical` treatment on iOS/macOS (downgraded without it) | Apple entitlements file | +| `com.apple.developer.usernotifications.critical-alerts` (granted by Apple on request) | `set_bypass_do_not_disturb` + `set_uses_critical_alerts` on iOS/macOS (downgraded without it) | Apple entitlements file | +| `com.apple.developer.usernotifications.communication` | communication-notification rendering of conversations on iOS/macOS (the `apple-communication` cargo feature; plain rendering without it) | Apple entitlements file | +| `NSUserActivityTypes` array containing `INSendMessageIntent` | the intent donations behind the `apple-communication` feature (Siri suggestions, Focus integration); donations fail silently without it | Apple Info.plist | +| Running from a bundled `.app` | the whole crate on macOS (`Error::NoAppBundle` otherwise) | macOS packaging | +| `` + the user granting Do-Not-Disturb access in settings | `set_bypass_do_not_disturb` on Android (silently ignored without the grant) | Android manifest + user grant | +| A registered AppUserModelID, passed to `set_app_id` | proper toast attribution on unpackaged Windows apps (dev fallback works without it); **required** for a `set_scheduled_time` toast to fire after the app exits | Windows installer/shortcut or MSIX | +| A `.desktop` file, its basename passed to `set_app_id` | the app's name/icon on Linux notifications | Linux packaging | + +Everything else — channels, groups, conversations, actions, replies, progress, +scheduling, settings read-back and deep links — needs no app-side +configuration on any platform. + +## Android integration + +The **minimum supported Android API level is 26 (Android 8.0)**: the bundled +Java helper is loaded via `InMemoryDexClassLoader`, which requires API 26, so +set `minSdk` to at least 26 in your app. + +On Android 13 and newer, your app manifest must declare the notification +permission: + +```xml + +``` + +Notable Android behaviors: + +- Every notification belongs to a channel — shown to users as a notification + "category" in system settings, where they can tune or disable each one. + Use `Notification::set_channel` to control the channel's id, + user-visible name, and importance; without one, a per-urgency default + channel is used ("Notifications", "Quiet notifications", or "Urgent + notifications"), so one notification's urgency can't affect another's. + Android applies a channel's importance (from `Urgency`) only when the + channel is first created; after that, only the user can change it, and + this crate never alters an existing channel's importance. +- `NotificationChannel::set_group` gathers related channels under a titled + section in system settings; the group is created on first use and its name + refreshes on later shows. +- Conversations publish a long-lived dynamic launcher shortcut per + conversation (that's how Android models them; no manifest changes needed). + If the user has customized a conversation in system settings, the crate + automatically posts under the system-created per-conversation channel. +- `Sound::Silent` posts through a low-importance `.silent` + variant channel (sound lives on the channel on Android 8+), which shows up + as its own channel in system settings. `Sound::Named` falls back to the + default sound for the same reason. +- Body taps: if the app's activity was already alive and the OS delivers the + tap via `onNewIntent` without recreating the activity, the app is brought + to the foreground but the `Activated` interaction is only observable if the + host activity calls `setIntent()` in `onNewIntent` (Makepad and most + NativeActivity-style hosts recreate or cold-start instead, where delivery + works). +- After a quick reply, the notification is removed automatically — Android + requires a replied-to notification to be updated or removed, otherwise its + reply UI spins forever. +- `Action::set_foreground` is ignored: broadcast-based actions can't bring + the app to the foreground since Android 12's notification-trampoline ban. + +## iOS and macOS integration + +On macOS, the app must be running from a bundled `.app`: a bare binary run +via `cargo run` gets `Error::NoAppBundle` from every function, because the +OS has no app registration to attribute notifications to. + +- `Urgency::Critical` maps to the time-sensitive interruption level, which + needs the `com.apple.developer.usernotifications.time-sensitive` + entitlement to take full effect (the system downgrades it otherwise). + Urgency is ignored on iOS 14/macOS 11 and older. +- Images must be a file type and size that UserNotifications accepts as an + attachment (PNG/JPEG/GIF, up to ~10 MB). The crate attaches a temporary + copy, so your original file stays where it is (the system consumes the + attached file). +- While the app is in the foreground, notifications are still presented + (banner + sound + badge) via the delegate. +- iOS shows at most 4 action buttons; extras aren't displayed. +- If your app has its own notification settings screen, call + `set_provides_notification_settings(true)` before `request_permission` and + handle `InteractionKind::OpenSettings`; the OS settings UI then links users + straight to it. + +## Windows integration + +Windows attributes toasts to an AppUserModelID (AUMID): + +- Packaged apps (MSIX) have one automatically. +- Unpackaged apps should call `set_app_id` with the AUMID their installer + registered (e.g., via a Start Menu shortcut). Without one, a built-in + system AUMID is borrowed so toasts still show during development — but + they're attributed to "Windows PowerShell". + +Toast activation only reaches the app while it's running; relaunch-on-click +would require a registered COM activator, which this crate doesn't provide. +`set_badge_count` and `set_group` are ignored on Windows. + +### Windows: known gaps (TBD) + +Windows has the largest distance between what the OS offers packaged/registered +apps and what an unpackaged app gets, so a few things are known gaps we plan to +address rather than silent limitations: + +- **Relaunch-on-click**: interactions are lost once the app exits. The planned + fix is opt-in protocol activation (toasts carrying a custom URL scheme the + app registers as its handler), which would deliver taps and button presses + after a relaunch — though quick-reply text can only ever arrive while the + app is running. +- **Scheduled toasts deliver no interactions**: `ScheduledToastNotification` + cannot carry the in-process event handlers at all, so clicks on a scheduled + toast are currently lost even while the app runs. The same protocol + activation work would fix this. +- **AUMID registration**: without `set_app_id` and a registered AppUserModelID, + toasts are attributed to "Windows PowerShell" (dev fallback) and scheduled + toasts won't fire after the app exits. A registration helper may be provided + separately. +- **Persistent toasts need a button**: Windows only honors the stays-on-screen + reminder mode when the toast has at least one action. +- **Per-section settings**: Windows "toast collections" could give named + sub-groups with their own row in Settings, but appear to require packaged + (MSIX) identity and offer no read-back of the user's per-collection choices. +- **`active_notification_ids` only covers this run**: toast tags are hashes, + so notifications from a previous run can't be mapped back to their ids. + +## Linux integration + +Call `set_app_id` with the basename of your app's `.desktop` file so the +notification daemon can look up your app's name and icon; without it, the +daemon shows the executable's name and no icon. + +- Action buttons (and body-click `Activated` interactions, which use the + spec's `"default"` action) require the daemon to advertise the `actions` + capability — GNOME, KDE, and most others do. +- Quick replies use the `inline-reply` capability (KDE Plasma has it); on + daemons without it, reply actions degrade to plain buttons that arrive as + `InteractionKind::Action`. +- Some daemons emit a close event right after an action is invoked, so an + `Activated`/`Action` interaction may be followed by a spurious `Dismissed`. + +## What maps where + +Not every builder option exists on every platform; unsupported options are +simply ignored there: + +| Option | Android | iOS/macOS | Windows | Linux | +| ------------------- | ------- | --------- | ------- | ----- | +| title/body | ✓ | ✓ | ✓ | ✓ | +| subtitle | ✓ (sub-text) | ✓ | ✓ (3rd line) | ✓ (appended to body) | +| actions & replies | ✓ | ✓ | ✓ | ✓ (daemon-dependent) | +| id replace/cancel | ✓ | ✓ | ✓ | ✓ (within one app run) | +| urgency | ✓ (channel importance) | ✓ (interruption level) | ✓ (scenario/suppress) | ✓ (urgency hint) | +| sound | default/silent | ✓ | ✓ | ✓ | +| image | ✓ (big picture) | ✓ (attachment) | ✓ (hero image) | ✓ (image-path hint) | +| badge count | ✓ (setNumber) | ✓ | – | – | +| group/thread | ✓ | ✓ | – | – | +| timeout | – | – | ✓ (expiration) | ✓ (expire timeout) | +| metadata round-trip | ✓ | ✓ | ✓ | ✓ | +| dismissed events | ✓ | ✓ | ✓ (explicit dismissal) | ✓ (reason 2) | +| channels (categories) & groups | ✓ | – | – | – | +| conversations | ✓ (11+, with message history) | ✓ (thread grouping; full communication rendering with the `apple-communication` feature) | – | ✓ (advisory hint) | +| progress bars | ✓ | – | ✓ (in-place updates) | ✓ (`value` hint) | +| scheduled delivery | ✓ (in-process) | ✓ (OS-side) | ✓ (OS-side) | ✓ (in-process) | +| persistent/ongoing | ✓ | – | ✓ (stays on screen) | ✓ (never expires) | +| lock-screen privacy | ✓ | – | – | – | +| event timestamps | ✓ | – | – | – | +| DnD bypass | ✓ (with user grant) | ✓ (with entitlement) | – | – | +| quiet permission | ✓ (reports state) | ✓ (provisional) | ✓ | ✓ | +| active-notification query | ✓ | ✓ | ✓ (this run) | ✓ (this run) | +| app badge control (`set_app_badge`) | – (follows notifications by design) | ✓ (iOS 16+/macOS 13+) | ✓ | ✓ (Unity LauncherEntry, desktop-dependent) | +| settings read-back | ✓ (per channel/conversation) | ✓ (app-level) | ✓ (app-level) | ✓ (service reachability) | +| open settings UI | ✓ (down to one conversation) | ✓ (iOS: app page; macOS: system pane, best-effort) | ✓ (system page) | – | +| `OpenSettings` hook | – | ✓ | – | – | diff --git a/crates/notifications/build.rs b/crates/notifications/build.rs new file mode 100644 index 0000000..72ab946 --- /dev/null +++ b/crates/notifications/build.rs @@ -0,0 +1,70 @@ +use std::{env, fs, path::PathBuf}; + +const JAVA_FILES_RELATIVE_PATHS: &[&str] = &[ + "src/sys/android/Notifications.java", + "src/sys/android/NotificationPermissionFragment.java", +]; + +fn main() { + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); + + if target_os == "android" { + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + + let android_jar_path = + android_build::android_jar(None).expect("Failed to find android.jar"); + + // Compile all `.java` files into `.class` files. + let mut java_build = android_build::JavaBuild::new(); + java_build + .class_path(android_jar_path.clone()) + .classes_out_dir(out_dir.clone()); + for relative_path in JAVA_FILES_RELATIVE_PATHS { + println!("cargo:rerun-if-changed={relative_path}"); + java_build.file(manifest_dir.join(relative_path)); + } + assert!( + java_build + .compile() + .expect("failed to acquire exit status for javac invocation") + .success(), + "javac invocation failed" + ); + + // Collect every generated `.class` file (there may be more than one per source file, e.g. + // inner and synthetic classes) so they all land in the single `classes.dex`. + let classes_dir = out_dir.join("robius").join("notifications"); + let class_files: Vec = fs::read_dir(&classes_dir) + .expect("failed to read compiled classes directory") + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.extension().is_some_and(|ext| ext == "class")) + .collect(); + assert!( + !class_files.is_empty(), + "no compiled .class files found in {}", + classes_dir.display() + ); + + let d8_jar_path = android_build::android_d8_jar(None).expect("Failed to find d8.jar"); + + let mut d8 = android_build::JavaRun::new(); + d8.class_path(d8_jar_path) + .main_class("com.android.tools.r8.D8") + .arg("--classpath") + .arg(android_jar_path) + .arg("--min-api") + .arg("26") + .arg("--output") + .arg(&out_dir); + for class_file in &class_files { + d8.arg(class_file); + } + assert!( + d8.run() + .expect("failed to acquire exit status for java d8.jar invocation") + .success(), + "java d8.jar invocation failed" + ); + } +} diff --git a/crates/notifications/src/error.rs b/crates/notifications/src/error.rs new file mode 100644 index 0000000..08b282d --- /dev/null +++ b/crates/notifications/src/error.rs @@ -0,0 +1,107 @@ +pub type Result = std::result::Result; + +/// Errors encountered when showing or managing system notifications. +#[derive(Debug)] +pub enum Error { + /// Couldn't acquire the Android environment. + /// + /// See the `robius-android-env` crate for more details. + AndroidEnvironment, + #[cfg(target_os = "android")] + Java(jni::errors::Error), + #[cfg(target_os = "windows")] + Windows(windows::core::Error), + #[cfg(target_os = "linux")] + DBus(zbus::Error), + /// A notification image or other filesystem operation failed. + Io(std::io::Error), + /// The notification has no title and no body. + Empty, + /// A notification id, action, channel, or other field was malformed + /// or otherwise invalid (e.g., empty or duplicate action ids). + InvalidNotification, + /// The user or system hasn't permitted this app to show notifications. + /// + /// Use [`request_permission`](crate::request_permission) to ask for permission first. + PermissionDenied, + /// The app isn't running from a proper app bundle, so the OS has nowhere + /// to attribute (or route interactions of) its notifications. + /// + /// This mainly happens on macOS when running a bare binary (e.g., via + /// `cargo run`) instead of a bundled `.app`. + NoAppBundle, + /// An interaction handler has already been set; only one is allowed. + HandlerAlreadySet, + /// No notification service is available to show notifications, + /// e.g., no notification daemon is running on Linux. + NoService, + /// This platform is unsupported. + Unsupported, + /// An unknown error occurred. + Unknown, +} + +#[cfg(target_os = "android")] +impl From for Error { + fn from(value: jni::errors::Error) -> Self { + Self::Java(value) + } +} + +#[cfg(target_os = "windows")] +impl From for Error { + fn from(value: windows::core::Error) -> Self { + Self::Windows(value) + } +} + +#[cfg(target_os = "linux")] +impl From for Error { + fn from(value: zbus::Error) -> Self { + Self::DBus(value) + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::AndroidEnvironment => f.write_str("couldn't access the Android/Java environment"), + #[cfg(target_os = "android")] + Error::Java(err) => write!(f, "Java error: {err}"), + #[cfg(target_os = "windows")] + Error::Windows(err) => write!(f, "Windows API error: {err}"), + #[cfg(target_os = "linux")] + Error::DBus(err) => write!(f, "D-Bus error: {err}"), + Error::Io(err) => write!(f, "I/O error: {err}"), + Error::Empty => f.write_str("the notification has no title or body"), + Error::InvalidNotification => f.write_str("invalid notification field"), + Error::PermissionDenied => f.write_str("no permission to show notifications"), + Error::NoAppBundle => f.write_str("the app isn't running from an app bundle"), + Error::HandlerAlreadySet => f.write_str("an interaction handler was already set"), + Error::NoService => f.write_str("no notification service is available"), + Error::Unsupported => f.write_str("this platform is unsupported"), + Error::Unknown => f.write_str("unknown error"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io(err) => Some(err), + #[cfg(target_os = "android")] + Error::Java(err) => Some(err), + #[cfg(target_os = "windows")] + Error::Windows(err) => Some(err), + #[cfg(target_os = "linux")] + Error::DBus(err) => Some(err), + _ => None, + } + } +} diff --git a/crates/notifications/src/lib.rs b/crates/notifications/src/lib.rs new file mode 100644 index 0000000..4c3c5c6 --- /dev/null +++ b/crates/notifications/src/lib.rs @@ -0,0 +1,1576 @@ +//! Multi-platform abstractions for showing system notifications +//! and handling the user's interactions with them. +//! +//! This crate covers the whole notification round trip: your app shows a +//! notification with [`Notification::show`], the user taps it, presses one of +//! its action buttons, or types into its quick-reply field, and your app gets +//! that [`Interaction`] back through the handler you registered with +//! [`set_interaction_handler`]. +//! +//! ## Platform behavior +//! All types & functions in this crate are completely platform-independent, +//! so your app code doesn't need to deal with any of this, but here are some +//! details about how things are implemented on a per-platform basis. +//! * **Android**: notifications are posted via `NotificationManager` using +//! notification channels, with action buttons and `RemoteInput` quick replies. +//! * **Minimum API level: 26 (Android 8.0).** The bundled Java helper is loaded +//! via `InMemoryDexClassLoader`, which requires API 26. Set `minSdk` to at +//! least 26 in your app. +//! * On Android 13 and newer, your app manifest must declare the +//! `android.permission.POST_NOTIFICATIONS` permission, and you must call +//! [`request_permission`] before notifications can be shown. +//! * Tapping the notification body launches (or re-focuses) your app. +//! See the README for the details of when the tap interaction itself +//! can be delivered to your handler. +//! * **iOS** and **macOS**: notifications use `UNUserNotificationCenter` from the +//! UserNotifications framework, with full support for action buttons, +//! quick replies, and interactions delivered even after an app relaunch. +//! * You must call [`request_permission`] before notifications can be shown. +//! * On macOS, the app must be running from a bundled `.app` (a bare binary +//! run via `cargo run` gets [`Error::NoAppBundle`], because the system +//! has no app registration to attribute notifications to). +//! * **Windows**: notifications are WinRT toast notifications, with action +//! buttons and quick-reply text boxes. +//! * Interactions are delivered only while the app is running; a toast +//! activated after the app exits doesn't relaunch it. +//! * Unpackaged apps (no MSIX/sparse package) don't have a registered +//! AppUserModelID by default; see [`set_app_id`] for how that's handled. +//! * **Linux**: notifications use the standard `org.freedesktop.Notifications` +//! D-Bus service, which works on both X11 and Wayland. +//! * Action buttons are shown if the notification daemon supports them +//! (GNOME, KDE, and most others do). +//! * Quick-reply actions degrade to plain buttons unless the daemon +//! supports inline replies (KDE Plasma does). +//! * Interactions are delivered only while the app is running. +//! +//! ## Terminology +//! Notification APIs use overlapping words for different things, +//! so here's how this crate's names map onto each platform's: +//! * [`NotificationChannel`] (via [`set_channel`](Notification::set_channel)) +//! is an Android notification channel, which Android's own settings UI +//! presents to users as a notification **"category"** (e.g., "Messages"). +//! * [`NotificationChannel::set_group`] is an Android notification channel +//! *group*: a titled **section of categories** in that same settings UI +//! (e.g., one section per account). +//! * [`Notification::set_group`] is unrelated to either of the above: it's the +//! visual-stacking **thread** id (iOS/macOS "thread identifier", Android +//! notification group) that piles related notifications together on screen. +//! * [`Conversation`] (via [`set_conversation`](Notification::set_conversation)) +//! is an ongoing chat with a person or group — "conversation" is the +//! platforms' own term: Android's Conversations section and per-conversation +//! settings, and the conversation id in Apple's communication notifications. +//! * iOS's `UNNotificationCategory` is none of the above: it's Apple's +//! action-set descriptor, which this crate manages internally whenever you +//! [`add_action`](Notification::add_action); it never appears in this API. +//! +//! ## Completion +//! A successful [`Notification::show`] means the notification was handed off +//! to the OS, not that it was displayed or seen: the OS may still suppress it, +//! e.g., due to a Do-Not-Disturb mode or missing permission. On Android, a +//! missing notification permission is reported synchronously as +//! [`Error::PermissionDenied`]. +//! +//! ## Interactions and thread contexts +//! Register your handler with [`set_interaction_handler`] as early as possible +//! during app startup: interactions can arrive at any moment, including ones +//! that launched your app (e.g., the user tapped a notification of an app that +//! wasn't running, on platforms that support relaunch-on-tap). +//! Interactions that arrive before a handler is set are queued up +//! and delivered as soon as one is set. +//! +//! The handler may run on any thread (a platform callback thread, the main UI +//! thread, or a background thread), so use a communication primitive like a +//! channel to get interactions over to your app's UI/main logic, or something +//! similar from your UI toolkit, e.g., `Cx::post_action` in Makepad. +//! +//! ## Examples +//! +//! ```no_run +//! use robius_notifications::{Action, Interaction, InteractionKind, Notification}; +//! +//! // Do this once, as early as possible at app startup. +//! robius_notifications::set_interaction_handler(|interaction: Interaction| { +//! match interaction.kind { +//! InteractionKind::Activated => { +//! println!("notification {:?} was tapped", interaction.notification_id); +//! } +//! InteractionKind::Reply { text, .. } => println!("user replied: {text}"), +//! _ => {} +//! } +//! }).expect("failed to set interaction handler"); +//! +//! // Ask once at startup; platforms without a permission prompt report granted immediately. +//! robius_notifications::request_permission(|granted| { +//! if !matches!(granted, Ok(true)) { +//! return; +//! } +//! Notification::new() +//! .set_id("new-message") +//! .set_title("New message") +//! .set_body("Hello from Robius!") +//! .add_action(Action::button("mark-read", "Mark as read")) +//! .add_action(Action::reply("reply", "Reply").set_placeholder("Type a reply…")) +//! .add_metadata("conversation", "robius") +//! .show() +//! .expect("failed to show notification"); +//! }).expect("failed to request notification permission"); +//! ``` + +mod error; +mod sys; + +// Compile-checks the README's code examples along with the doctests. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, OnceLock, + }, + time::{Duration, SystemTime}, +}; + +pub use error::{Error, Result}; + +pub(crate) type PermissionCallback = Box) + Send + 'static>; +pub(crate) type SettingsCallback = Box) + Send + 'static>; +pub(crate) type ActiveIdsCallback = Box>) + Send + 'static>; +type InteractionHandler = Arc; + +/// A system notification builder. +#[derive(Clone, Debug, Default)] +pub struct Notification { + options: NotificationOptions, +} + +impl Notification { + /// Creates a new notification builder. + pub fn new() -> Self { + Self::default() + } + + /// Sets a stable identifier for this notification. + /// + /// Showing another notification with the same id replaces the earlier one. + /// The id is also how [`cancel`] and [`Interaction::notification_id`] + /// refer back to this notification. + /// If you don't set an id, a unique one is generated at [`show`](Self::show) time. + #[must_use] + pub fn set_id(mut self, id: impl Into) -> Self { + self.options.id = id.into(); + self + } + + /// Sets the notification's title, its most prominent line of text. + #[must_use] + pub fn set_title(mut self, title: impl Into) -> Self { + self.options.title = Some(title.into()); + self + } + + /// Sets the notification's main body text. + #[must_use] + pub fn set_body(mut self, body: impl Into) -> Self { + self.options.body = Some(body.into()); + self + } + + /// Sets a secondary line of text shown near the title, where supported. + /// + /// iOS/macOS show this as the subtitle, Android as the sub-text, + /// and Windows as an extra line. Linux appends it to the body. + #[must_use] + pub fn set_subtitle(mut self, subtitle: impl Into) -> Self { + self.options.subtitle = Some(subtitle.into()); + self + } + + /// Sets the channel (notification category) this notification belongs to. + /// + /// This primarily matters on Android, where every notification belongs to a + /// channel that users can manage in system settings; the channel is created + /// on first use. Without one, notifications go to a default "Notifications" + /// channel. Other platforms mostly ignore this. + #[doc(alias = "set_category")] + #[must_use] + pub fn set_channel(mut self, channel: NotificationChannel) -> Self { + self.options.channel = Some(channel); + self + } + + /// Sets how prominently this notification should interrupt the user. + /// + /// On iOS/macOS, [`Urgency::Critical`] maps to the time-sensitive + /// interruption level, which only takes full effect if the app has the + /// `com.apple.developer.usernotifications.time-sensitive` entitlement + /// in its entitlements file; the system quietly downgrades it otherwise. + #[must_use] + pub fn set_urgency(mut self, urgency: Urgency) -> Self { + self.options.urgency = Some(urgency); + self + } + + /// Sets the sound played when this notification is shown. + /// + /// The platform default sound is used if not set. + #[must_use] + pub fn set_sound(mut self, sound: Sound) -> Self { + self.options.sound = Some(sound); + self + } + + /// Sets the number shown on the app's icon badge (iOS/macOS), + /// or the notification count number (Android). Other platforms ignore it. + #[must_use] + pub fn set_badge_count(mut self, count: u32) -> Self { + self.options.badge_count = Some(count); + self + } + + /// Sets a group/thread id used to visually group related notifications + /// together (e.g., all messages in one conversation), where supported. + /// This is iOS/macOS's "thread identifier"; it is not a notification + /// category (for that, see [`set_channel`](Self::set_channel)). + #[doc(alias = "thread")] + #[must_use] + pub fn set_group(mut self, group: impl Into) -> Self { + self.options.group = Some(group.into()); + self + } + + /// Attaches an image from a filesystem path, shown alongside or below + /// the notification text, where supported. + #[must_use] + pub fn set_image>(mut self, path: P) -> Self { + self.options.image = Some(path.as_ref().to_owned()); + self + } + + /// Sets how long the notification stays on screen before auto-dismissing, + /// on platforms that support an explicit timeout (Linux, Windows). + /// Others let the OS decide. + #[must_use] + pub fn set_timeout(mut self, timeout: Duration) -> Self { + self.options.timeout = Some(timeout); + self + } + + /// Attaches an app-defined key-value pair to this notification. + /// + /// Metadata isn't shown to the user; it comes back to you in + /// [`Interaction::metadata`] so you can route the interaction, e.g., + /// which conversation to open when a message notification is tapped. + #[must_use] + pub fn add_metadata(mut self, key: impl Into, value: impl Into) -> Self { + self.options.metadata.push((key.into(), value.into())); + self + } + + /// Adds an interactive action (a button or quick-reply field) to this notification. + /// + /// Platforms limit how many actions are actually shown + /// (Android: 3, iOS: 4, Windows: 5), so put the important ones first. + #[must_use] + pub fn add_action(mut self, action: Action) -> Self { + self.options.actions.push(action); + self + } + + /// Associates this notification with an ongoing [`Conversation`]. + /// + /// On Android 11 and newer, this gives the notification the platform's full + /// conversation treatment: it appears in the dedicated "Conversations" + /// section of the shade, and the user gets per-conversation settings + /// (priority, silent, bubble). You can read the priority/urgency/sound + /// state back with [`notification_settings`] and open those settings + /// with [`open_notification_settings`]. + /// On iOS/macOS, the conversation groups notifications like + /// [`set_group`](Self::set_group) does (an explicit `set_group` wins). + /// With the `apple-communication` cargo feature enabled, it additionally + /// gets Apple's communication-notification treatment on iOS 15+/macOS 12+: + /// the conversation icon as the sender's (or group's) avatar, with the + /// donated intents feeding Siri suggestions and Focus. (Focus's + /// "allowed people" breakthrough matches the user's contacts, which needs + /// contact details this API doesn't carry yet, so that part doesn't apply.) + /// **That requires the app to hold the + /// `com.apple.developer.usernotifications.communication` entitlement AND + /// list `INSendMessageIntent` in the `NSUserActivityTypes` array of its + /// `Info.plist`**; otherwise (or on older systems) it falls back to the + /// plain rendering. + /// Windows and Linux have no conversation concept and mostly ignore this. + #[must_use] + pub fn set_conversation(mut self, conversation: Conversation) -> Self { + self.options.conversation = Some(conversation); + self + } + + /// Shows a progress bar on the notification, e.g., for a download. + /// + /// Use [`update_progress`] to advance it in place without re-alerting the + /// user. Android and Windows render a real progress bar; some Linux + /// daemons show it (the `value` hint); iOS/macOS have no notification + /// progress concept, so it's a no-op there. + #[must_use] + pub fn set_progress(mut self, progress: Progress) -> Self { + self.options.progress = Some(progress); + self + } + + /// Sets the event time this notification is about, shown in place of the + /// time it was posted (Android). Other platforms always show the delivery + /// time, so this is a no-op there. Unset = the platform default (posting time). + #[must_use] + pub fn set_timestamp(mut self, timestamp: SystemTime) -> Self { + self.options.timestamp = Some(timestamp); + self + } + + /// Marks this notification as persistent/ongoing, where supported. + /// + /// * **Android**: an "ongoing" notification the user can't swipe away + /// (Android 14 lets users dismiss most of them anyway). + /// * **Windows**: the toast stays on screen until acted on + /// (reminder-style) — but only when the notification has at least one + /// action button; Windows ignores the reminder mode otherwise. + /// * **Linux**: the notification never auto-expires. + /// * **iOS/macOS**: no such concept (banners are always transient); no-op. + /// + /// Off by default, matching every platform's default. + #[must_use] + pub fn set_persistent(mut self, persistent: bool) -> Self { + self.options.persistent = persistent; + self + } + + /// Sets how much of this notification appears on the lock screen (Android). + /// + /// Unset = the platform default: the user's own lock-screen notification + /// setting decides. The other platforms manage lock-screen privacy + /// entirely at the OS level, so this is a no-op there. + #[must_use] + pub fn set_lock_screen_visibility(mut self, visibility: LockScreenVisibility) -> Self { + self.options.lock_screen_visibility = Some(visibility); + self + } + + /// Asks for this notification to break through Do-Not-Disturb / Focus + /// modes, where supported. Off by default (notifications respect + /// Do-Not-Disturb everywhere by default). + /// + /// * **iOS/macOS**: delivered as a critical alert. **Requires the + /// Apple-granted `com.apple.developer.usernotifications.critical-alerts` + /// entitlement in the app**, and [`set_uses_critical_alerts`]`(true)` + /// must be called before [`request_permission`]; without both, the + /// system silently downgrades it to a normal notification. + /// * **Android**: applied to the notification's channel when the channel + /// is first created (like importance, only the user can change it + /// afterwards). **It only takes effect if the user has granted the app + /// Do-Not-Disturb access** (Settings → Notifications → Do Not Disturb + /// access); declare `android.permission.ACCESS_NOTIFICATION_POLICY` in + /// the manifest so the app appears in that settings list. Without the + /// grant, the flag is silently ignored. + /// * **Windows/Linux**: no per-notification override exists; no-op + /// (use [`Urgency::Critical`] for the closest behavior). + #[must_use] + pub fn set_bypass_do_not_disturb(mut self, bypass: bool) -> Self { + self.options.bypass_dnd = bypass; + self + } + + /// Schedules this notification to be shown later instead of immediately. + /// + /// * **iOS/macOS/Windows**: scheduled by the OS, so it fires even if the + /// app has exited by then. (On Windows, interactions with a scheduled + /// toast are only delivered if the app is running when it fires.) + /// * **Android/Linux**: scheduled by an in-process timer, so it only + /// fires while the app is still running. + /// + /// A time in the past shows the notification immediately. [`cancel`] with + /// this notification's id also cancels a still-pending scheduled showing. + #[must_use] + pub fn set_scheduled_time(mut self, time: SystemTime) -> Self { + self.options.scheduled_time = Some(time); + self + } + + /// Shows this notification. + /// + /// A successful return means the notification was handed off to the OS, + /// not that it was displayed; see the crate-level docs on Completion. + pub fn show(self) -> Result<()> { + let mut options = self.options; + options.validate()?; + if options.id.is_empty() { + options.id = generated_id(); + } + + // A past (or immediate) scheduled time just means "now". + if let Some(time) = options.scheduled_time { + if time.duration_since(SystemTime::now()).unwrap_or(Duration::ZERO) + < Duration::from_millis(50) + { + options.scheduled_time = None; + } else if sys::NATIVE_SCHEDULING { + // The OS shows it later. Drop any stale progress entry from an + // earlier same-id show: a scheduled notification can't be + // progress-updated until it's showing (see [`update_progress`]). + progress_cache().lock().unwrap().remove(&options.id); + return sys::show(options); + } else { + // No OS-side scheduling here: an in-process timer fires it + // later (and dies with the process; see set_scheduled_time). + return schedule_fallback(options); + } + } + + // An immediate show supersedes any still-pending scheduled showing of this id. + fallback_scheduled().lock().unwrap().remove(&options.id); + show_now(options) + } +} + +/// An interactive element on a notification: a button or a quick-reply field. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Action { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) kind: ActionKind, + pub(crate) placeholder: Option, + pub(crate) destructive: bool, + pub(crate) foreground: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ActionKind { + Button, + Reply, +} + +impl Action { + /// Creates a plain button action. + /// + /// The `id` is how [`InteractionKind::Action`] refers back to this action; + /// the `title` is the button label the user sees. + pub fn button(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + kind: ActionKind::Button, + placeholder: None, + destructive: false, + foreground: false, + } + } + + /// Creates a quick-reply action: a button that opens an inline text input. + /// + /// The submitted text arrives as [`InteractionKind::Reply`]. On platforms + /// without inline text input in notifications, this degrades to a plain + /// button that arrives as [`InteractionKind::Action`]. + pub fn reply(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + kind: ActionKind::Reply, + placeholder: None, + destructive: false, + foreground: false, + } + } + + /// Sets the placeholder text shown in an empty quick-reply input, where supported. + #[must_use] + pub fn set_placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = Some(placeholder.into()); + self + } + + /// Marks this action as destructive (e.g., "Delete"), where supported. + /// iOS/macOS show destructive actions in red. + #[must_use] + pub fn set_destructive(mut self, destructive: bool) -> Self { + self.destructive = destructive; + self + } + + /// Requests that pressing this action also brings the app to the foreground, + /// where supported (iOS/macOS). By default, actions are handled without + /// opening the app. + #[must_use] + pub fn set_foreground(mut self, foreground: bool) -> Self { + self.foreground = foreground; + self + } +} + +/// An ongoing conversation (a direct message or group chat with one or more +/// people) that notifications can belong to, via [`Notification::set_conversation`]. +/// +/// "Conversation" is the platforms' own term for this: Android shows these in +/// a dedicated "Conversations" section with per-conversation user settings +/// (Android 11+), and iOS/macOS model conversations in their communication +/// notification APIs. The `id` should be a stable identifier for the chat +/// (e.g., your app's chat/room/thread id), so that repeated notifications for +/// the same chat share one conversation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Conversation { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) icon: Option, + pub(crate) group_conversation: bool, +} + +impl Conversation { + /// Creates a conversation with the given stable `id` and user-visible `name`. + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + icon: None, + group_conversation: false, + } + } + + /// Sets the conversation's avatar/icon image from a filesystem path, where supported. + #[must_use] + pub fn set_icon>(mut self, path: P) -> Self { + self.icon = Some(path.as_ref().to_owned()); + self + } + + /// Marks this as a group conversation (multiple people) rather than a 1:1 chat. + #[must_use] + pub fn set_group_conversation(mut self, group_conversation: bool) -> Self { + self.group_conversation = group_conversation; + self + } +} + +/// Progress shown on a notification, e.g., for a download or upload. +/// +/// Set it with [`Notification::set_progress`], then advance it in place with +/// [`update_progress`]. Progress notifications alert the user only when first +/// shown, not on every update, matching the platforms' own behavior. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Progress { + /// A busy indicator with no specific completion amount. + Indeterminate, + /// A bar filled to `current` out of `total`. + /// A `current` greater than `total` is treated as complete. + Determinate { + /// How much is done so far. + current: u32, + /// The total amount of work; must be non-zero. + total: u32, + }, +} + +/// How much of a notification appears on the device's lock screen (Android). +/// +/// Used with [`Notification::set_lock_screen_visibility`]; when unset, the +/// platform default applies (the user's own lock-screen setting decides). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LockScreenVisibility { + /// The full content is shown on the lock screen. + Public, + /// The notification's presence is shown, but the system redacts its content. + Private, + /// The notification doesn't appear on the lock screen at all. + Secret, +} + +/// One message of a conversation's accumulated history (see +/// [`Notification::set_conversation`]); only rendered on Android. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ConversationMessage { + pub(crate) sender: String, + pub(crate) text: String, + /// Milliseconds since the Unix epoch. + pub(crate) timestamp_ms: u64, +} + +/// A user-manageable category of notifications, aka an Android notification channel. +/// +/// On Android, every notification belongs to a channel — shown to users as a +/// notification "category" in system settings — and users can tune or disable +/// each one separately. The channel is created the first time it's used; its +/// name and importance are user-visible. Related channels can be gathered +/// under a titled section via [`set_group`](Self::set_group). +/// Other platforms currently ignore channels. +#[doc(alias("category", "categories"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NotificationChannel { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: Option, + pub(crate) importance: Urgency, + /// A `(group id, group name)` pair; see [`NotificationChannel::set_group`]. + pub(crate) group: Option<(String, String)>, +} + +impl NotificationChannel { + /// Creates a new channel with the given stable `id` and user-visible `name`. + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + description: None, + importance: Urgency::Normal, + group: None, + } + } + + /// Puts this channel in a user-visible group: a titled section that + /// related channels (notification categories) appear under in the + /// system settings UI (Android). Other platforms ignore this. + #[doc(alias("category_group", "section"))] + #[must_use] + pub fn set_group(mut self, group_id: impl Into, group_name: impl Into) -> Self { + self.group = Some((group_id.into(), group_name.into())); + self + } + + /// Sets the user-visible description of what this channel is for. + #[must_use] + pub fn set_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Sets the importance of all notifications in this channel. + /// + /// Note: Android only applies this when the channel is first created; + /// after that, only the user can change it (in system settings). + #[must_use] + pub fn set_importance(mut self, importance: Urgency) -> Self { + self.importance = importance; + self + } +} + +/// How prominently a notification should interrupt the user. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Urgency { + /// Delivered quietly: no popup banner, no sound. + Low, + /// The platform's regular notification behavior. + #[default] + Normal, + /// Time-sensitive: pops up over other content and stays visible longer, + /// where supported. + Critical, +} + +/// The sound played when a notification is shown. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Sound { + /// The platform's default notification sound. + #[default] + Default, + /// No sound. + Silent, + /// A named sound, resolved in a platform-specific way: a sound file name + /// bundled with the app (iOS/macOS), a system sound name (macOS, e.g., + /// "Ping"), a freedesktop sound-theme name (Linux), or a `ms-winsoundevent` + /// name (Windows). Platforms fall back to the default sound if the name + /// can't be resolved. + Named(String), +} + +/// A user interaction with a previously shown notification, +/// delivered to the handler registered via [`set_interaction_handler`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Interaction { + /// The id of the notification that was interacted with, + /// as set (or generated) by [`Notification::set_id`]. + pub notification_id: String, + /// What the user did. + pub kind: InteractionKind, + /// The metadata that was attached to the notification + /// via [`Notification::add_metadata`]. + pub metadata: Vec<(String, String)>, +} + +/// The ways a user can interact with a notification. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InteractionKind { + /// The user tapped/clicked the notification body itself. + Activated, + /// The user dismissed the notification without acting on it, + /// on platforms that report dismissals. + Dismissed, + /// The user pressed one of the notification's action buttons. + Action { + /// The id of the [`Action`] that was pressed. + id: String, + }, + /// The user submitted text through a quick-reply action. + Reply { + /// The id of the quick-reply [`Action`]. + action_id: String, + /// The text the user submitted. + text: String, + }, + /// The user asked to see this app's own notification settings screen from + /// the OS settings UI (iOS/macOS; see [`set_provides_notification_settings`]). + /// + /// [`Interaction::notification_id`] is empty unless the user got there + /// from one specific notification. + OpenSettings, +} + +/// Registers the handler called whenever the user interacts with one of this +/// app's notifications. +/// +/// Call this once, as early as possible during app startup, so that +/// interactions that launched your app still reach the handler; see the +/// crate-level docs. Interactions that arrive before any handler is set are +/// queued and delivered once one is set. +/// +/// The handler may run on any thread; use a channel or similar to communicate +/// with your app's UI thread. +/// +/// Returns [`Error::HandlerAlreadySet`] if a handler was already registered. +pub fn set_interaction_handler(handler: F) -> Result<()> +where + F: Fn(Interaction) + Send + Sync + 'static, +{ + if matches!(&*handler_state().lock().unwrap(), HandlerState::Set(_)) { + return Err(Error::HandlerAlreadySet); + } + + // Let the backend set up its listener (delegate, receiver, etc.) first; + // if that fails, no handler is installed at all. + sys::init_interaction_listener()?; + + // Install the handler and take the queue in one critical section, so a + // concurrent caller can't clobber an already-installed handler. + let (handler, pending) = { + let mut state = handler_state().lock().unwrap(); + match &mut *state { + HandlerState::Set(_) => return Err(Error::HandlerAlreadySet), + HandlerState::Pending(pending) => { + let pending = std::mem::take(pending); + let handler: InteractionHandler = Arc::new(handler); + *state = HandlerState::Set(handler.clone()); + (handler, pending) + } + } + }; + + // Deliver anything queued before the handler existed, without holding the lock. + for interaction in pending { + handler(interaction); + } + + Ok(()) +} + +/// Asks the user for permission to show notifications. +/// +/// The callback receives `Ok(true)` if permission was granted (or isn't needed +/// on the current platform), and `Ok(false)` if the user or system denied it. +/// The callback may be invoked from any thread, before or after this returns. +/// +/// * **Android 13+**: shows the system permission prompt; your app manifest +/// must declare `android.permission.POST_NOTIFICATIONS`. Android 12 and +/// older report `Ok(true)` unless the user disabled the app's notifications. +/// * **iOS/macOS**: shows the system permission prompt (once; afterwards it +/// just reports the user's standing decision). +/// * **Windows**: no permission prompt exists; reports whether toast +/// notifications are currently enabled for this app. +/// * **Linux**: no permission concept at all; always reports `Ok(true)`. +pub fn request_permission(on_result: F) -> Result<()> +where + F: FnOnce(Result) + Send + 'static, +{ + sys::request_permission(Box::new(on_result), false) +} + +/// Like [`request_permission`], but never shows the user a prompt. +/// +/// * **iOS/macOS**: requests provisional authorization: no prompt appears, +/// and notifications are delivered quietly (straight to Notification +/// Center, no banner or sound) until the user upgrades or disables them +/// from there. Reports `Ok(true)` immediately. +/// * **Android**: just reports the current permission state, without +/// prompting (Android has no quiet-delivery permission). +/// * **Windows/Linux**: identical to [`request_permission`], which never +/// prompts on these platforms anyway. +pub fn request_provisional_permission(on_result: F) -> Result<()> +where + F: FnOnce(Result) + Send + 'static, +{ + sys::request_permission(Box::new(on_result), true) +} + +/// Updates the progress bar of an already-shown notification, in place and +/// without re-alerting the user. +/// +/// The notification must have been shown with +/// [`Notification::set_progress`] during this app run; otherwise this +/// returns [`Error::InvalidNotification`]. On platforms that don't render +/// progress (iOS/macOS), this is a no-op. +/// +/// If the user has already dismissed the notification, the update is +/// dropped (Android) or lands quietly in the notification center (Windows) — +/// it never re-alerts. A notification still pending via +/// [`Notification::set_scheduled_time`] can't be updated until it has +/// actually been shown. +pub fn update_progress(id: &str, progress: Progress) -> Result<()> { + if id.is_empty() || matches!(progress, Progress::Determinate { total: 0, .. }) { + return Err(Error::InvalidNotification); + } + let options = { + let mut cache = progress_cache().lock().unwrap(); + let options = cache.get_mut(id).ok_or(Error::InvalidNotification)?; + options.progress = Some(progress); + options.clone() + }; + sys::update_progress(&options) +} + +/// Asks the OS which of this app's notifications are still showing (in the +/// system tray / notification center), reporting their ids to the callback. +/// The callback may be invoked from any thread, before or after this returns. +/// +/// Platform notes: Android and iOS/macOS report all of the app's delivered, +/// still-visible notifications. Windows and Linux can only report +/// notifications shown by this run of the app. Notifications scheduled for +/// later (via [`Notification::set_scheduled_time`]) are not included. +pub fn active_notification_ids(on_result: F) -> Result<()> +where + F: FnOnce(Result>) + Send + 'static, +{ + sys::active_notification_ids(Box::new(on_result)) +} + +/// What a notification-settings query or settings screen should be scoped to. +/// +/// Only Android has real per-channel and per-conversation settings; the other +/// platforms treat every scope as [`SettingsScope::App`]. See +/// [`notification_settings`] and [`open_notification_settings`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SettingsScope { + /// The app's overall notification settings. + App, + /// One [`NotificationChannel`]'s settings. + Channel { + /// The [`NotificationChannel`] id. + channel_id: String, + }, + /// One [`Conversation`]'s settings within a channel (Android 11+). + Conversation { + /// The id of the [`NotificationChannel`] the conversation's + /// notifications were shown under. + channel_id: String, + /// The [`Conversation`] id. + conversation_id: String, + }, +} + +impl SettingsScope { + fn validate(&self) -> Result<()> { + let valid = match self { + SettingsScope::App => true, + SettingsScope::Channel { channel_id } => !channel_id.trim().is_empty(), + SettingsScope::Conversation { channel_id, conversation_id } => { + !channel_id.trim().is_empty() && !conversation_id.trim().is_empty() + } + }; + if valid { + Ok(()) + } else { + Err(Error::InvalidNotification) + } + } +} + +/// A snapshot of the user's notification settings, as far as the current +/// platform reports them back to apps. +/// +/// `enabled` is known everywhere (except Linux, which can only report whether +/// a notification service exists); every other field is `None` on platforms +/// that don't expose it. Android reports the most: per-channel and +/// per-conversation urgency, sound, badge, and user-customization state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NotificationSettings { + /// Whether notifications in the requested scope can be shown at all. + pub enabled: bool, + /// The user's effective urgency for the channel/conversation (Android). + pub urgency: Option, + /// Whether sound is allowed. + pub sound_enabled: Option, + /// Whether badges are allowed. + pub badge_enabled: Option, + /// Whether the user themselves changed these settings, as opposed to the + /// values the app created the channel with (Android 10+). + pub customized_by_user: Option, + /// Whether the user marked this conversation as a priority + /// conversation (Android 11+, [`SettingsScope::Conversation`] only). + pub priority_conversation: Option, +} + +/// Asks the OS what the user's notification settings currently are, +/// for the given `scope`. +/// +/// The callback may be invoked from any thread, before or after this returns. +/// +/// * **Android**: reports real per-channel/per-conversation settings the user +/// picked in system settings. A [`SettingsScope::Channel`] for a channel +/// that was never created reports app-level settings. +/// * **iOS/macOS/Windows**: every scope reports the app-level settings. +/// * **Linux**: `enabled` just reflects whether a notification service is +/// reachable; nothing else is reported. +pub fn notification_settings(scope: SettingsScope, on_result: F) -> Result<()> +where + F: FnOnce(Result) + Send + 'static, +{ + scope.validate()?; + sys::notification_settings(scope, Box::new(on_result)) +} + +/// Opens the OS's notification settings UI for this app, at the given `scope`. +/// +/// * **Android**: opens the app's notification settings, one channel's +/// settings, or one conversation's settings (Android 11+; older versions +/// fall back to the channel). +/// * **iOS**: opens the app's page in the Settings app (every scope). +/// * **macOS/Windows**: opens the system notification settings (every scope), +/// where the user can find the app; macOS support is best-effort. +/// * **Linux**: no standard way to open notification settings; returns +/// [`Error::Unsupported`]. +pub fn open_notification_settings(scope: SettingsScope) -> Result<()> { + scope.validate()?; + sys::open_notification_settings(scope) +} + +/// Sets the app's icon badge to `count` (`0` clears it), independent of any +/// notification. +/// +/// A notification's own [`set_badge_count`](Notification::set_badge_count) +/// applies when it's delivered; this function updates the badge at any other +/// time — most importantly to clear it once the user has caught up. +/// +/// * **iOS/macOS**: sets the app icon badge (iOS 16+/macOS 13+; a quiet +/// no-op on older systems). Requires notification permission. +/// * **Windows**: sets the taskbar/start badge for the app's AppUserModelID. +/// * **Android**: launcher badges derive from the app's active notifications +/// by design — the number comes from each notification's +/// [`set_badge_count`](Notification::set_badge_count), and cancelling +/// notifications retracts it — so this is a no-op (Android has no public +/// app-badge API). +/// * **Linux**: best-effort via the de-facto Unity LauncherEntry signal, +/// honored by some desktops/docks (KDE Plasma, elementary); requires +/// [`set_app_id`] so the badge can be attributed to the app's `.desktop` +/// entry. Silently ignored elsewhere. +pub fn set_app_badge(count: u32) -> Result<()> { + sys::set_app_badge(count) +} + +/// Declares whether this app has its own in-app notification settings screen. +/// Call this before [`request_permission`]. +/// +/// On iOS/macOS, the system then offers a link to the app from its +/// notification settings UI; the user choosing it is delivered to your +/// interaction handler as [`InteractionKind::OpenSettings`], and your app +/// should navigate to its notification settings screen. Other platforms +/// ignore this. +pub fn set_provides_notification_settings(provides: bool) { + PROVIDES_SETTINGS_UI.store(provides, Ordering::Relaxed); +} + +static PROVIDES_SETTINGS_UI: AtomicBool = AtomicBool::new(false); + +/// Whether the app declared an in-app notification settings screen. +#[cfg_attr(not(target_vendor = "apple"), allow(dead_code))] +pub(crate) fn provides_notification_settings() -> bool { + PROVIDES_SETTINGS_UI.load(Ordering::Relaxed) +} + +/// Declares that this app uses critical alerts, i.e., notifications that +/// break through Do-Not-Disturb via +/// [`Notification::set_bypass_do_not_disturb`]. Call this before +/// [`request_permission`]. Off by default. +/// +/// This only matters on iOS/macOS, where critical-alert authorization must be +/// requested up front, **and the app must hold the Apple-granted +/// `com.apple.developer.usernotifications.critical-alerts` entitlement** +/// (requested from Apple, then added to the app's entitlements file); +/// without it, the authorization request and the alerts are silently +/// downgraded. Other platforms ignore this. +pub fn set_uses_critical_alerts(uses: bool) { + USES_CRITICAL_ALERTS.store(uses, Ordering::Relaxed); +} + +static USES_CRITICAL_ALERTS: AtomicBool = AtomicBool::new(false); + +/// Whether the app declared that it uses critical alerts. +#[cfg_attr(not(target_vendor = "apple"), allow(dead_code))] +pub(crate) fn uses_critical_alerts() -> bool { + USES_CRITICAL_ALERTS.load(Ordering::Relaxed) +} + +/// Removes a previously shown notification from the system tray / +/// notification center, by the id it was shown with. +/// +/// Removing an id that's no longer (or was never) shown is not an error. +pub fn cancel(id: &str) -> Result<()> { + if id.is_empty() { + return Err(Error::InvalidNotification); + } + // Also kills a still-pending fallback-scheduled showing and drops + // whatever we remembered for progress updates. + fallback_scheduled().lock().unwrap().remove(id); + progress_cache().lock().unwrap().remove(id); + sys::cancel(id) +} + +/// Removes all of this app's notifications from the system tray / notification center. +pub fn cancel_all() -> Result<()> { + fallback_scheduled().lock().unwrap().clear(); + progress_cache().lock().unwrap().clear(); + sys::cancel_all() +} + +/// Sets the app identity used when showing notifications, on platforms that +/// need one. Call this before showing any notification. +/// +/// * **Windows**: the AppUserModelID (AUMID) the toasts are attributed to. +/// Packaged apps (MSIX) don't need this. For unpackaged apps, pass the AUMID +/// your installer registered (e.g., via a Start Menu shortcut); if you don't +/// set one, a built-in system AUMID is borrowed so toasts still show during +/// development, but they'll be attributed to "Windows PowerShell". +/// * **Linux**: the basename of your app's `.desktop` file, which notification +/// daemons use to look up your app's name and icon. +/// * **Android/iOS/macOS**: ignored; identity comes from the app package/bundle. +pub fn set_app_id(app_id: impl Into) { + *app_id_state().lock().unwrap() = Some(app_id.into()); +} + +/// Options collected by [`Notification`]. +#[derive(Clone, Debug, Default)] +pub(crate) struct NotificationOptions { + /// Empty until explicitly set; [`Notification::show`] fills in a generated + /// id, so backends can rely on this being non-empty. + pub(crate) id: String, + pub(crate) title: Option, + pub(crate) body: Option, + pub(crate) subtitle: Option, + pub(crate) channel: Option, + pub(crate) urgency: Option, + pub(crate) sound: Option, + pub(crate) badge_count: Option, + pub(crate) group: Option, + pub(crate) image: Option, + pub(crate) timeout: Option, + pub(crate) metadata: Vec<(String, String)>, + pub(crate) actions: Vec, + pub(crate) conversation: Option, + /// The conversation's recent messages, filled in by `show()` from the + /// process-wide history whenever `conversation` is set. + pub(crate) conversation_messages: Vec, + pub(crate) progress: Option, + pub(crate) timestamp: Option, + pub(crate) persistent: bool, + pub(crate) lock_screen_visibility: Option, + pub(crate) bypass_dnd: bool, + pub(crate) scheduled_time: Option, +} + +impl NotificationOptions { + fn validate(&self) -> Result<()> { + fn is_empty_or_unset(text: &Option) -> bool { + match text.as_deref() { + Some(text) => text.trim().is_empty(), + None => true, + } + } + + if is_empty_or_unset(&self.title) && is_empty_or_unset(&self.body) { + return Err(Error::Empty); + } + + for action in &self.actions { + if action.id.trim().is_empty() || action.title.trim().is_empty() { + return Err(Error::InvalidNotification); + } + let duplicates = self + .actions + .iter() + .filter(|other| other.id == action.id) + .count(); + if duplicates > 1 { + return Err(Error::InvalidNotification); + } + } + + for (key, _value) in &self.metadata { + if key.trim().is_empty() { + return Err(Error::InvalidNotification); + } + } + + if let Some(channel) = &self.channel { + if channel.id.trim().is_empty() || channel.name.trim().is_empty() { + return Err(Error::InvalidNotification); + } + if let Some((group_id, group_name)) = &channel.group { + if group_id.trim().is_empty() || group_name.trim().is_empty() { + return Err(Error::InvalidNotification); + } + } + } + + if let Some(conversation) = &self.conversation { + if conversation.id.trim().is_empty() || conversation.name.trim().is_empty() { + return Err(Error::InvalidNotification); + } + if conversation + .icon + .as_deref() + .is_some_and(|path| path.as_os_str().is_empty()) + { + return Err(Error::InvalidNotification); + } + } + + if self.image.as_deref().is_some_and(|path| path.as_os_str().is_empty()) { + return Err(Error::InvalidNotification); + } + + if let Some(Progress::Determinate { total: 0, .. }) = self.progress { + return Err(Error::InvalidNotification); + } + + Ok(()) + } +} + +/// Generates a process-unique notification id for notifications without one. +fn generated_id() -> String { + static NEXT_ID: AtomicUsize = AtomicUsize::new(0); + format!( + "robius-notification-{}-{}", + std::process::id(), + NEXT_ID.fetch_add(1, Ordering::Relaxed), + ) +} + +enum HandlerState { + /// Interactions that arrived before any handler was set. + Pending(Vec), + Set(InteractionHandler), +} + +fn handler_state() -> &'static Mutex { + static HANDLER_STATE: Mutex = Mutex::new(HandlerState::Pending(Vec::new())); + &HANDLER_STATE +} + +fn app_id_state() -> &'static Mutex> { + static APP_ID: Mutex> = Mutex::new(None); + &APP_ID +} + +/// The last-shown options of progress notifications, kept so +/// [`update_progress`] can re-render everything with just the bar moved. +fn progress_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(Mutex::default) +} + +fn remember_progress(options: &NotificationOptions) { + let mut cache = progress_cache().lock().unwrap(); + if options.progress.is_some() { + cache.insert(options.id.clone(), options.clone()); + } else { + // Re-showing an id without progress means it's no longer a progress notification. + cache.remove(&options.id); + } +} + +/// Shows a notification right now: renders the conversation-history snapshot, +/// hands the notification to the backend, and commits the bookkeeping +/// (progress cache, shared history) only if the OS actually took it. +fn show_now(mut options: NotificationOptions) -> Result<()> { + let pending_message = prepare_conversation_history(&mut options); + let bookkeeping = options.clone(); + let result = sys::show(options); + if result.is_ok() { + remember_progress(&bookkeeping); + commit_conversation_message(pending_message); + } + result +} + +/// How many recent messages a conversation notification shows (Android). +const CONVERSATION_HISTORY_LIMIT: usize = 8; + +/// Builds the message this notification adds to its conversation, filling +/// `options.conversation_messages` with the history plus it — WITHOUT +/// committing to the shared history yet (that happens once it's shown, +/// so failed or cancelled showings never pollute later notifications). +fn prepare_conversation_history( + options: &mut NotificationOptions, +) -> Option<(String, ConversationMessage)> { + let conversation = options.conversation.as_ref()?; + // No body = nothing readable to accumulate (e.g. a bare title-only ping). + let text = match options.body.as_deref().map(str::trim) { + Some(text) if !text.is_empty() => text.to_owned(), + _ => return None, + }; + let sender = options + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .unwrap_or(&conversation.name) + .to_owned(); + let timestamp_ms = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_millis() as u64; + let message = ConversationMessage { sender, text, timestamp_ms }; + + let mut snapshot = conversation_histories() + .lock() + .unwrap() + .get(&conversation.id) + .cloned() + .unwrap_or_default(); + snapshot.push(message.clone()); + if snapshot.len() > CONVERSATION_HISTORY_LIMIT { + let excess = snapshot.len() - CONVERSATION_HISTORY_LIMIT; + snapshot.drain(..excess); + } + options.conversation_messages = snapshot; + Some((conversation.id.clone(), message)) +} + +/// Forgets the accumulated message history of a [`Conversation`], so the next +/// notification in it starts fresh instead of re-showing older messages. +/// +/// Call this when the user has read the conversation in your app (typically +/// together with [`cancel`]), matching how messaging apps clear a +/// conversation's notification once it's been seen. No-op if the conversation +/// has no history. +pub fn clear_conversation_history(conversation_id: &str) { + conversation_histories().lock().unwrap().remove(conversation_id); +} + +/// Commits a shown notification's message to its conversation's shared history. +fn commit_conversation_message(pending: Option<(String, ConversationMessage)>) { + let Some((conversation_id, message)) = pending else { + return; + }; + let mut histories = conversation_histories().lock().unwrap(); + let history = histories.entry(conversation_id).or_default(); + history.push(message); + if history.len() > CONVERSATION_HISTORY_LIMIT { + let excess = history.len() - CONVERSATION_HISTORY_LIMIT; + history.drain(..excess); + } +} + +fn conversation_histories() -> &'static Mutex>> { + static HISTORIES: OnceLock>>> = OnceLock::new(); + HISTORIES.get_or_init(Mutex::default) +} + +/// Pending fallback-scheduled notifications: id -> generation. A re-show or +/// cancel bumps/removes the entry, so the sleeping timer thread notices its +/// showing is stale and does nothing. +fn fallback_scheduled() -> &'static Mutex> { + static SCHEDULED: OnceLock>> = OnceLock::new(); + SCHEDULED.get_or_init(Mutex::default) +} + +/// In-process scheduling for platforms without OS-side scheduling: one timer +/// thread per pending notification. Dies with the process, as documented. +fn schedule_fallback(mut options: NotificationOptions) -> Result<()> { + static GENERATION: AtomicUsize = AtomicUsize::new(0); + let generation = GENERATION.fetch_add(1, Ordering::Relaxed) as u64; + let time = options.scheduled_time.take(); + let delay = time + .and_then(|time| time.duration_since(SystemTime::now()).ok()) + .unwrap_or(Duration::ZERO); + + fallback_scheduled() + .lock() + .unwrap() + .insert(options.id.clone(), generation); + + std::thread::Builder::new() + .name("robius-notifications-timer".to_owned()) + .spawn(move || { + std::thread::sleep(delay); + // Hold the lock across the show, so a concurrent cancel() can't + // slip in between our staleness check and the notification + // actually appearing. + let mut scheduled = fallback_scheduled().lock().unwrap(); + // Only fire if we're still the latest scheduled showing of this id. + if scheduled.get(&options.id) != Some(&generation) { + return; + } + scheduled.remove(&options.id); + // Conversation history and progress bookkeeping happen at fire + // time, inside show_now, so they reflect what actually displayed. + let _ = show_now(options); + }) + .map(|_| ()) + .map_err(Error::Io) +} + +/// The app id set via [`set_app_id`], if any. +#[cfg_attr(not(any(target_os = "windows", target_os = "linux")), allow(dead_code))] +pub(crate) fn app_id() -> Option { + app_id_state().lock().unwrap().clone() +} + +/// Called by the platform backends to hand a user interaction to the app, +/// or queue it up if the app hasn't registered its handler yet. +#[cfg_attr(target_family = "wasm", allow(dead_code))] +pub(crate) fn deliver_interaction(interaction: Interaction) { + let handler = { + let mut state = handler_state().lock().unwrap(); + match &mut *state { + HandlerState::Pending(pending) => { + pending.push(interaction); + return; + } + HandlerState::Set(handler) => handler.clone(), + } + }; + // Run the app's handler outside the lock, in case it shows another notification. + handler(interaction); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_notification_is_invalid() { + assert!(matches!( + Notification::new().options.validate(), + Err(Error::Empty) + )); + assert!(matches!( + Notification::new().set_title(" ").set_body("").options.validate(), + Err(Error::Empty) + )); + } + + #[test] + fn title_or_body_alone_is_valid() { + assert!(Notification::new().set_title("t").options.validate().is_ok()); + assert!(Notification::new().set_body("b").options.validate().is_ok()); + } + + #[test] + fn empty_and_duplicate_action_ids_are_invalid() { + let invalid_notifications = [ + Notification::new().set_title("t").add_action(Action::button("", "OK")), + Notification::new().set_title("t").add_action(Action::button("ok", " ")), + Notification::new() + .set_title("t") + .add_action(Action::button("ok", "OK")) + .add_action(Action::reply("ok", "Reply")), + Notification::new().set_title("t").add_metadata("", "value"), + Notification::new() + .set_title("t") + .set_channel(NotificationChannel::new("", "Messages")), + Notification::new() + .set_title("t") + .set_channel(NotificationChannel::new("messages", "Messages").set_group("", "Work")), + Notification::new() + .set_title("t") + .set_conversation(Conversation::new("", "Chat")), + Notification::new() + .set_title("t") + .set_conversation(Conversation::new("chat-1", " ")), + ]; + + for notification in invalid_notifications { + assert!(matches!( + notification.options.validate(), + Err(Error::InvalidNotification) + )); + } + } + + #[test] + fn valid_builder_options_pass_validation() { + let notification = Notification::new() + .set_id("new-message") + .set_title("New message") + .set_body("Hello from Robius!") + .set_subtitle("Robius") + .set_channel( + NotificationChannel::new("messages", "Messages") + .set_description("New chat messages") + .set_importance(Urgency::Critical) + .set_group("work", "Work account"), + ) + .set_conversation( + Conversation::new("chat-42", "Team chat") + .set_group_conversation(true), + ) + .set_urgency(Urgency::Critical) + .set_sound(Sound::Default) + .set_badge_count(3) + .set_group("conversation-42") + .set_timeout(Duration::from_secs(10)) + .add_metadata("conversation", "42") + .add_action(Action::button("mark-read", "Mark as read").set_destructive(true)) + .add_action(Action::reply("reply", "Reply").set_placeholder("Type a reply…")); + + assert!(notification.options.validate().is_ok()); + } + + #[test] + fn settings_scopes_validate_their_ids() { + assert!(SettingsScope::App.validate().is_ok()); + assert!(SettingsScope::Channel { channel_id: "messages".to_owned() }.validate().is_ok()); + assert!(SettingsScope::Conversation { + channel_id: "messages".to_owned(), + conversation_id: "chat-42".to_owned(), + } + .validate() + .is_ok()); + + let invalid_scopes = [ + SettingsScope::Channel { channel_id: " ".to_owned() }, + SettingsScope::Conversation { + channel_id: "messages".to_owned(), + conversation_id: "".to_owned(), + }, + SettingsScope::Conversation { + channel_id: "".to_owned(), + conversation_id: "chat-42".to_owned(), + }, + ]; + for scope in invalid_scopes { + assert!(matches!(scope.validate(), Err(Error::InvalidNotification))); + } + } + + #[test] + fn zero_total_progress_is_invalid() { + assert!(matches!( + Notification::new() + .set_title("t") + .set_progress(Progress::Determinate { current: 0, total: 0 }) + .options + .validate(), + Err(Error::InvalidNotification) + )); + assert!(Notification::new() + .set_title("t") + .set_progress(Progress::Indeterminate) + .options + .validate() + .is_ok()); + assert!(matches!( + update_progress("dl", Progress::Determinate { current: 1, total: 0 }), + Err(Error::InvalidNotification) + )); + // Never shown with progress in this run: nothing to update. + assert!(matches!( + update_progress("never-shown-progress-id", Progress::Indeterminate), + Err(Error::InvalidNotification) + )); + } + + #[test] + fn conversation_history_accumulates_and_trims() { + // prepare + commit = what a successful show does. + let show = |n: u32| { + let mut options = Notification::new() + .set_title(format!("sender {n}")) + .set_body(format!("message {n}")) + .set_conversation(Conversation::new("history-test", "Chat")) + .options; + let pending = prepare_conversation_history(&mut options); + commit_conversation_message(pending); + options + }; + + let first = show(0); + assert_eq!(first.conversation_messages.len(), 1); + assert_eq!(first.conversation_messages[0].sender, "sender 0"); + assert_eq!(first.conversation_messages[0].text, "message 0"); + + let last = (1..=20).map(show).last().unwrap(); + assert_eq!(last.conversation_messages.len(), CONVERSATION_HISTORY_LIMIT); + // Oldest entries got trimmed; the newest is last. + assert_eq!(last.conversation_messages.last().unwrap().text, "message 20"); + assert_eq!( + last.conversation_messages.first().unwrap().text, + format!("message {}", 21 - CONVERSATION_HISTORY_LIMIT), + ); + + // A body-less notification adds nothing to the history. + let mut silent = Notification::new() + .set_title("sender") + .set_conversation(Conversation::new("history-test", "Chat")) + .options; + assert!(prepare_conversation_history(&mut silent).is_none()); + assert!(silent.conversation_messages.is_empty()); + + // An uncommitted prepare (a failed/cancelled show) must not leak + // into the shared history that later notifications render. + let mut failed = Notification::new() + .set_title("sender") + .set_body("never shown") + .set_conversation(Conversation::new("history-test", "Chat")) + .options; + let _uncommitted = prepare_conversation_history(&mut failed); + let after = show(99); + assert!(!after + .conversation_messages + .iter() + .any(|message| message.text == "never shown")); + } + + #[test] + fn cancel_kills_a_pending_fallback_scheduled_showing() { + let options = Notification::new() + .set_id("scheduled-test") + .set_title("t") + .set_scheduled_time(SystemTime::now() + Duration::from_secs(600)) + .options; + schedule_fallback(options).unwrap(); + assert!(fallback_scheduled().lock().unwrap().contains_key("scheduled-test")); + + // sys::cancel fails on unsupported/unbundled hosts; the pending + // entry must be gone regardless. + let _ = cancel("scheduled-test"); + assert!(!fallback_scheduled().lock().unwrap().contains_key("scheduled-test")); + } + + #[test] + fn generated_ids_are_unique() { + assert_ne!(generated_id(), generated_id()); + } + + #[test] + fn interactions_are_queued_until_a_handler_is_set() { + let interaction = Interaction { + notification_id: "queued".to_owned(), + kind: InteractionKind::Action { id: "ok".to_owned() }, + metadata: vec![("k".to_owned(), "v".to_owned())], + }; + deliver_interaction(interaction.clone()); + + let HandlerState::Pending(pending) = &*handler_state().lock().unwrap() else { + panic!("expected interactions to be queued while no handler is set"); + }; + assert_eq!(pending.last(), Some(&interaction)); + } +} diff --git a/crates/notifications/src/sys.rs b/crates/notifications/src/sys.rs new file mode 100644 index 0000000..aaab6c4 --- /dev/null +++ b/crates/notifications/src/sys.rs @@ -0,0 +1,18 @@ +cfg_if::cfg_if! { + if #[cfg(target_os = "android")] { + mod android; + pub(crate) use android::*; + } else if #[cfg(target_vendor = "apple")] { + mod apple; + pub(crate) use apple::*; + } else if #[cfg(target_os = "windows")] { + mod windows; + pub(crate) use windows::*; + } else if #[cfg(target_os = "linux")] { + mod linux; + pub(crate) use linux::*; + } else { + mod unsupported; + pub(crate) use unsupported::*; + } +} diff --git a/crates/notifications/src/sys/android/NotificationPermissionFragment.java b/crates/notifications/src/sys/android/NotificationPermissionFragment.java new file mode 100644 index 0000000..21b6552 --- /dev/null +++ b/crates/notifications/src/sys/android/NotificationPermissionFragment.java @@ -0,0 +1,214 @@ +/* This file is compiled by build.rs. */ + +package robius.notifications; + +import android.app.Activity; +import android.app.Fragment; +import android.app.FragmentManager; +import android.app.NotificationManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; + +/* + * Headless fragment for the POST_NOTIFICATIONS runtime permission request (Android 13+). + * Fragment.requestPermissions sends the result straight back here, so we don't have to touch the + * activity's own onRequestPermissionsResult. Nothing here blocks, since the Rust caller might be + * on a thread that must never wait on the Android UI thread. + */ +public class NotificationPermissionFragment extends Fragment { + // Must be <= 0xffff: android.app.Fragment encodes its index in the upper 16 bits of the code. + private static final int REQUEST_CODE = 0x4e74; + private static final String TAG = "robius.notifications.NotificationPermissionFragment"; + private static final String POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS"; + + // True while a request's fragment is in flight (or about to be). The by-tag lookup alone + // can miss one, since commitAllowingStateLoss only enqueues the add. Only touched on the + // UI thread. + private static boolean requestInFlight = false; + + // Raw pointer to a boxed Rust callback. 0 means no callback - a framework-recreated + // instance, or we already delivered. + private long callbackPtr; + private boolean launched; + + /* + * The name and signature of this function must be kept in sync with + * `PERMISSION_CALLBACK_NAME` and `PERMISSION_CALLBACK_SIGNATURE` in `class.rs`. + */ + static native void rustPermissionCallback(long callbackPtr, boolean granted); + + // The framework needs this to recreate the fragment (e.g. after the process dies). A recreated + // one has no callback pointer and just removes itself in `onResume`. + public NotificationPermissionFragment() { + this.callbackPtr = 0; + this.launched = false; + } + + private NotificationPermissionFragment(long callbackPtr) { + this.callbackPtr = callbackPtr; + this.launched = false; + } + + // Reports the standing permission state, or shows the system prompt on Android 13+. + // Doesn't block - posts to the UI thread and returns. Java owns `callbackPtr` now and + // always delivers it exactly once. + public static void request(Activity activity, long callbackPtr) { + activity.runOnUiThread(() -> requestOnUiThread(activity, callbackPtr)); + } + + private static void requestOnUiThread(Activity activity, long callbackPtr) { + // Once the fragment owns the pointer, only it delivers the callback, so we deliver just + // once. Before that, we deliver here if anything fails. + boolean fragmentOwnsPtr = false; + try { + // Below Android 13 there's no permission prompt; the user's standing setting is all + // there is. + if (Build.VERSION.SDK_INT < 33) { + rustPermissionCallback(callbackPtr, notificationsEnabled(activity)); + return; + } + if (activity.checkSelfPermission(POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + || notificationsEnabled(activity)) { + rustPermissionCallback(callbackPtr, true); + return; + } + if (activity.isFinishing() || activity.isDestroyed()) { + rustPermissionCallback(callbackPtr, false); + return; + } + + if (requestInFlight) { + // Another request is already in flight; drop this duplicate. + rustPermissionCallback(callbackPtr, false); + return; + } + + FragmentManager fm = activity.getFragmentManager(); + Fragment existing = fm.findFragmentByTag(TAG); + if (existing instanceof NotificationPermissionFragment) { + if (((NotificationPermissionFragment) existing).hasCallback()) { + // Backstop for the same in-flight case; shouldn't happen with the flag. + rustPermissionCallback(callbackPtr, false); + return; + } + // Remove any old leftover before adding a fresh one. + fm.beginTransaction().remove(existing).commitNowAllowingStateLoss(); + } + + NotificationPermissionFragment fragment = + new NotificationPermissionFragment(callbackPtr); + requestInFlight = true; + fragmentOwnsPtr = true; + // Commit async so this can't throw right here and race the fragment's own delivery. + fm.beginTransaction().add(fragment, TAG).commitAllowingStateLoss(); + } catch (Throwable t) { + if (!fragmentOwnsPtr) { + requestInFlight = false; + rustPermissionCallback(callbackPtr, false); + } + // Else the fragment owns the pointer and its onDestroy will deliver. + } + } + + // The standing permission state, same checks as the already-granted paths in + // `requestOnUiThread`, but without ever prompting. Used for provisional requests. + public static boolean currentPermissionState(Context context) { + try { + if (Build.VERSION.SDK_INT >= 33 + && context.checkSelfPermission(POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + return true; + } + return notificationsEnabled(context); + } catch (Throwable t) { + return false; + } + } + + private static boolean notificationsEnabled(Context context) { + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + return manager != null && manager.areNotificationsEnabled(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Survive rotation as the same instance, so the pending request keeps its callback pointer. + setRetainInstance(true); + } + + @Override + public void onResume() { + super.onResume(); + + // A framework-recreated instance has no callback, so it shouldn't stick around. + if (callbackPtr == 0) { + removeSelf(); + return; + } + + if (!launched) { + launched = true; + // Use the Fragment's own `requestPermissions` so the result routes back to us. + requestPermissions(new String[] { POST_NOTIFICATIONS }, REQUEST_CODE); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + if (requestCode != REQUEST_CODE) { + return; + } + + // An empty array (an interrupted request) counts as denied. + boolean granted = false; + for (int result : grantResults) { + if (result == PackageManager.PERMISSION_GRANTED) { + granted = true; + break; + } + } + + deliver(granted); + removeSelf(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + // A config change recreates us from the retained instance, so don't fire a spurious "denied". + Activity activity = getActivity(); + if (activity != null && activity.isChangingConfigurations()) { + return; + } + + // Real teardown before a result: deliver "denied" so the callback always runs exactly once + // and the boxed Rust callback gets freed (does nothing if already delivered). + deliver(false); + } + + private synchronized boolean hasCallback() { + return callbackPtr != 0; + } + + private synchronized void deliver(boolean granted) { + long ptr = callbackPtr; + callbackPtr = 0; + if (ptr != 0) { + // This fragment's request is done, so the next one may start. + requestInFlight = false; + rustPermissionCallback(ptr, granted); + } + } + + private void removeSelf() { + FragmentManager fm = getFragmentManager(); + if (fm != null) { + fm.beginTransaction().remove(this).commitAllowingStateLoss(); + } + } +} diff --git a/crates/notifications/src/sys/android/Notifications.java b/crates/notifications/src/sys/android/Notifications.java new file mode 100644 index 0000000..5eac4ea --- /dev/null +++ b/crates/notifications/src/sys/android/Notifications.java @@ -0,0 +1,924 @@ +/* This file is compiled by build.rs. */ + +package robius.notifications; + +import android.app.Activity; +import android.app.Application; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationChannelGroup; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Person; +import android.app.RemoteInput; +import android.content.ActivityNotFoundException; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.LocusId; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.Settings; +import android.service.notification.StatusBarNotification; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +public class Notifications { + /* These result codes must be kept in sync with `mod.rs`. */ + private static final int RESULT_OK = 0; + private static final int RESULT_PERMISSION_DENIED = 1; + private static final int RESULT_ERROR = 2; + /* No settings activity to open; the Rust side treats this as an unknown error. */ + private static final int RESULT_NO_SETTINGS_ACTIVITY = 3; + + /* These interaction kinds must be kept in sync with the `KIND_*` constants in `class.rs`. */ + private static final int KIND_ACTIVATED = 0; + private static final int KIND_DISMISSED = 1; + private static final int KIND_ACTION = 2; + private static final int KIND_REPLY = 3; + + private static final String ACTION_INTERACTION = "robius.notifications.INTERACTION"; + private static final String EXTRA_ID = "robius.notifications.extra.ID"; + private static final String EXTRA_KIND = "robius.notifications.extra.KIND"; + private static final String EXTRA_ACTION_ID = "robius.notifications.extra.ACTION_ID"; + private static final String EXTRA_METADATA_KEYS = "robius.notifications.extra.METADATA_KEYS"; + private static final String EXTRA_METADATA_VALUES = "robius.notifications.extra.METADATA_VALUES"; + private static final String EXTRA_TOKEN = "robius.notifications.extra.TOKEN"; + private static final String REMOTE_INPUT_KEY = "robius.notifications.REPLY"; + private static final String DATA_SCHEME = "robius-notification"; + /* Sound::Silent notifications post on a ".silent" variant channel. */ + private static final String SILENT_CHANNEL_SUFFIX = ".silent"; + /* Our group-summary notifications post under this tag prefix, so we can spot ours later. */ + private static final String SUMMARY_TAG_PREFIX = "robius.notifications.summary:"; + /* Summaries post under int id 1; user notifications always use id 0. That way a user id + that happens to start with the tag prefix can't be mistaken for one of our summaries. */ + private static final int SUMMARY_ID = 1; + + private static boolean receiverRegistered = false; + private static boolean activationWatcherRegistered = false; + + // One-shot tap tokens: each content intent gets a fresh token at show time, and + // `maybeDeliverActivation` refuses tokens it has already consumed in this process run. + private static final AtomicLong tokenCounter = new AtomicLong(); + private static final Set consumedTokens = + Collections.synchronizedSet(new HashSet()); + + /* + * The name and signature of this function must be kept in sync with + * `INTERACTION_CALLBACK_NAME` and `INTERACTION_CALLBACK_SIGNATURE` in `class.rs`. + */ + static native void rustInteractionCallback( + String notificationId, + int kind, + String actionId, + String replyText, + String[] metadataKeys, + String[] metadataValues); + + public static int show( + Context context, + String id, + String title, + String body, + String subtitle, + String channelId, + String channelName, + String channelDescription, + String[] channelGroup, // {group id, group name}, or null + int importance, // 0 = low, 1 = normal, 2 = critical + boolean silent, + boolean bypassDnd, + int badgeCount, // -1 when unset + String group, + String imagePath, + String[] conversation, // {id, name, icon path or null}, or null + boolean groupConversation, + String[] messageSenders, // conversation history, oldest first, or null + String[] messageTexts, + long[] messageTimestamps, // ms since epoch + int progressCurrent, + int progressTotal, // -1 = no progress, 0 = indeterminate, > 0 = determinate + long whenMs, // event timestamp in ms since epoch, -1 when unset + boolean ongoing, + int visibility, // -2 = unset; 0/1/2 = public/private/secret, as in `mod.rs` + String[] metadataKeys, + String[] metadataValues, + String[] actionIds, + String[] actionTitles, + int[] actionKinds, // 0 = button, 1 = reply + String[] actionPlaceholders, + boolean updateOnly) { // true = only re-post if the tag is still showing + try { + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) { + return RESULT_ERROR; + } + if (!manager.areNotificationsEnabled()) { + return RESULT_PERMISSION_DENIED; + } + // An update must never resurrect a notification the user dismissed; + // just drop it. Fresh shows always post. + if (updateOnly && !isActive(manager, id)) { + return RESULT_OK; + } + + // Make sure button presses and dismissals can actually reach us. + ensureReceiverRegistered(context); + + String effectiveChannelId = createChannel( + manager, channelId, channelName, channelDescription, channelGroup, + importance, silent, bypassDnd); + + // The conversation treatment needs the Person-based MessagingStyle APIs (28+). + boolean conversationStyle = conversation != null && Build.VERSION.SDK_INT >= 28; + Icon conversationIcon = conversationStyle ? decodeIcon(conversation[2]) : null; + if (conversationStyle && Build.VERSION.SDK_INT >= 30) { + publishConversationShortcut(context, conversation, conversationIcon); + // If the user customized this conversation, the system split it into its own + // channel; post there so their per-conversation settings actually apply. + NotificationChannel conversationChannel = + manager.getNotificationChannel(effectiveChannelId, conversation[0]); + if (conversationChannel != null + && conversationChannel.getConversationId() != null) { + effectiveChannelId = conversationChannel.getId(); + } + } + + Notification.Builder builder = new Notification.Builder(context, effectiveChannelId); + builder.setSmallIcon(smallIcon(context)); + builder.setAutoCancel(true); + if (title != null) { + builder.setContentTitle(title); + } + if (body != null) { + builder.setContentText(body); + } + if (subtitle != null) { + builder.setSubText(subtitle); + } + if (group != null) { + builder.setGroup(group); + } + if (badgeCount >= 0) { + builder.setNumber(badgeCount); + } + if (progressTotal >= 0) { + builder.setProgress(progressTotal, progressCurrent, progressTotal == 0); + // Progress notifications ding on first show only; updates and + // re-shows of the same tag stay quiet. + builder.setOnlyAlertOnce(true); + } + if (whenMs >= 0) { + builder.setWhen(whenMs); + builder.setShowWhen(true); + } + if (ongoing) { + builder.setOngoing(true); + } + switch (visibility) { + case 0: builder.setVisibility(Notification.VISIBILITY_PUBLIC); break; + case 1: builder.setVisibility(Notification.VISIBILITY_PRIVATE); break; + case 2: builder.setVisibility(Notification.VISIBILITY_SECRET); break; + default: break; // unset: leave the platform default + } + if (conversationStyle) { + applyMessagingStyle(builder, conversation, conversationIcon, groupConversation, + title, body, imagePath, messageSenders, messageTexts, messageTimestamps); + builder.setShortcutId(conversation[0]); + if (Build.VERSION.SDK_INT >= 29) { + builder.setLocusId(new LocusId(conversation[0])); + } + } else { + applyStyle(builder, body, imagePath); + } + + String[] keys = metadataKeys == null ? new String[0] : metadataKeys; + String[] values = metadataValues == null ? new String[0] : metadataValues; + + setContentIntent(context, builder, id, keys, values); + builder.setDeleteIntent( + interactionBroadcast(context, id, KIND_DISMISSED, null, keys, values, false)); + addActions(context, builder, id, keys, values, + actionIds, actionTitles, actionKinds, actionPlaceholders); + + // Same tag = replaces any still-visible notification with the same id. + manager.notify(id, 0, builder.build()); + if (group != null) { + postGroupSummary(context, manager, effectiveChannelId, group); + } + return RESULT_OK; + } catch (Throwable e) { + return RESULT_ERROR; + } + } + + public static int cancel(Context context, String id) { + try { + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) { + return RESULT_ERROR; + } + manager.cancel(id, 0); + // If that was a group's last real member, its summary shouldn't linger. + pruneGroupSummaries(manager, id); + return RESULT_OK; + } catch (Throwable e) { + return RESULT_ERROR; + } + } + + // Posts (or quietly refreshes) the summary notification that makes Android + // visually bundle a group's notifications together. + private static void postGroupSummary( + Context context, NotificationManager manager, String channelId, String group) { + Notification.Builder summary = new Notification.Builder(context, channelId); + summary.setSmallIcon(smallIcon(context)); + summary.setGroup(group); + summary.setGroupSummary(true); + summary.setOnlyAlertOnce(true); + // Only the children alert; otherwise the summary can ding too on first post. + summary.setGroupAlertBehavior(Notification.GROUP_ALERT_CHILDREN); + manager.notify(SUMMARY_TAG_PREFIX + group, SUMMARY_ID, summary.build()); + } + + // Whether a still-visible user notification (always posted at id 0) has this tag. + private static boolean isActive(NotificationManager manager, String tag) { + StatusBarNotification[] active = manager.getActiveNotifications(); + if (active == null) { + return false; + } + for (StatusBarNotification sbn : active) { + if (sbn.getId() == 0 && tag.equals(sbn.getTag())) { + return true; + } + } + return false; + } + + // Whether this is one of our group summaries. Both parts matter: user + // notifications post at id 0, so a user id starting with the prefix doesn't match. + private static boolean isOurSummary(StatusBarNotification sbn) { + String tag = sbn.getTag(); + return sbn.getId() == SUMMARY_ID && tag != null && tag.startsWith(SUMMARY_TAG_PREFIX); + } + + // Cancels our summary notifications whose group has no real member left. + // `cancelledTag` counts as already gone: the cancel just issued may not have + // reached the system's active list yet. + private static void pruneGroupSummaries(NotificationManager manager, String cancelledTag) { + StatusBarNotification[] active = manager.getActiveNotifications(); + if (active == null) { + return; + } + Set liveGroups = new HashSet(); + for (StatusBarNotification sbn : active) { + String tag = sbn.getTag(); + if (isOurSummary(sbn) || (tag != null && tag.equals(cancelledTag))) { + continue; + } + String group = sbn.getNotification().getGroup(); + if (group != null) { + liveGroups.add(group); + } + } + for (StatusBarNotification sbn : active) { + if (isOurSummary(sbn) + && !liveGroups.contains(sbn.getTag().substring(SUMMARY_TAG_PREFIX.length()))) { + manager.cancel(sbn.getTag(), SUMMARY_ID); + } + } + } + + // Prune wrapper for the user-driven removal paths (dismiss, tap, reply), which + // don't go through `cancel`; the removed tag counts as gone, as above. + private static void pruneAfterRemoval(Context context, String removedTag) { + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + pruneGroupSummaries(manager, removedTag); + } + } + + // Tags of this app's still-visible notifications (we always post with tag = the + // notification id), minus our internal group summaries. Returns null on error. + public static String[] activeNotificationIds(Context context) { + try { + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) { + return null; + } + StatusBarNotification[] active = manager.getActiveNotifications(); + if (active == null) { + return null; + } + ArrayList ids = new ArrayList(); + for (StatusBarNotification sbn : active) { + String tag = sbn.getTag(); + if (tag != null && !isOurSummary(sbn)) { + ids.add(tag); + } + } + return ids.toArray(new String[0]); + } catch (Throwable e) { + return null; + } + } + + public static int cancelAll(Context context) { + try { + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) { + return RESULT_ERROR; + } + manager.cancelAll(); + return RESULT_OK; + } catch (Throwable e) { + return RESULT_ERROR; + } + } + + // Called from Rust's `init_interaction_listener`. + public static int initListener(Activity activity) { + try { + ensureReceiverRegistered(activity); + ensureActivationWatcherRegistered(activity); + // Catch a cold start where the tapped-notification activity resumed before init ran. + maybeDeliverActivation(activity); + return RESULT_OK; + } catch (Throwable e) { + return RESULT_ERROR; + } + } + + // Snapshot of the user's notification settings for one scope, as + // [enabled, urgency, sound, badge, customized, priority] with -1 = unknown; + // must be kept in sync with `mod.rs`. A null channelId means app scope, a null + // conversationId means channel scope. Returns null on error. + public static int[] notificationSettings( + Context context, String channelId, String conversationId) { + try { + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) { + return null; + } + boolean appEnabled = manager.areNotificationsEnabled(); + int[] settings = new int[] { appEnabled ? 1 : 0, -1, -1, -1, -1, -1 }; + + // Per-conversation channels only exist on API 30+; older versions just report + // the parent channel. The two-arg lookup itself falls back to the parent too. + boolean conversationScope = conversationId != null && Build.VERSION.SDK_INT >= 30; + NotificationChannel channel = null; + if (channelId != null) { + channel = lookupChannel(manager, channelId, conversationId, conversationScope); + if (channel == null) { + // Sound::Silent notifications post on a ".silent" variant channel. + channel = lookupChannel(manager, channelId + SILENT_CHANNEL_SUFFIX, + conversationId, conversationScope); + } + } + if (channel == null) { + // Channel never created: the app-level settings are all there is. + return settings; + } + + // A blocked channel group silences all of its channels, without + // touching any channel's own importance. + boolean groupBlocked = false; + if (Build.VERSION.SDK_INT >= 28 && channel.getGroup() != null) { + NotificationChannelGroup group = + manager.getNotificationChannelGroup(channel.getGroup()); + groupBlocked = group != null && group.isBlocked(); + } + + settings[0] = appEnabled && !groupBlocked + && channel.getImportance() != NotificationManager.IMPORTANCE_NONE ? 1 : 0; + settings[1] = mapUrgency(channel.getImportance()); + // Sound only actually plays at default importance or higher (the settings + // "Silent" toggle lowers importance but leaves the sound Uri set). + settings[2] = channel.getImportance() >= NotificationManager.IMPORTANCE_DEFAULT + && channel.getSound() != null ? 1 : 0; + settings[3] = channel.canShowBadge() ? 1 : 0; + if (Build.VERSION.SDK_INT >= 29) { + boolean customized = channel.hasUserSetImportance() + || (Build.VERSION.SDK_INT >= 30 && channel.hasUserSetSound()); + settings[4] = customized ? 1 : 0; + } + if (conversationScope) { + settings[5] = channel.isImportantConversation() ? 1 : 0; + } + return settings; + } catch (Throwable e) { + return null; + } + } + + // The two-arg conversation lookup itself falls back to the parent channel (API 30+). + private static NotificationChannel lookupChannel( + NotificationManager manager, + String channelId, + String conversationId, + boolean conversationScope) { + return conversationScope + ? manager.getNotificationChannel(channelId, conversationId) + : manager.getNotificationChannel(channelId); + } + + // Maps a channel's importance back to our urgency (0 = low, 1 = normal, 2 = critical). + private static int mapUrgency(int importance) { + if (importance == NotificationManager.IMPORTANCE_NONE + || importance == NotificationManager.IMPORTANCE_UNSPECIFIED) { + return -1; + } + if (importance <= NotificationManager.IMPORTANCE_LOW) { + return 0; + } + if (importance == NotificationManager.IMPORTANCE_DEFAULT) { + return 1; + } + return 2; + } + + // Opens the system notification settings at the given scope; null ids as above. + public static int openSettings(Context context, String channelId, String conversationId) { + try { + Intent intent; + if (channelId == null) { + intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS); + } else { + // Sound::Silent notifications post on a ".silent" variant channel; if only + // that variant exists, deep-link to it instead of a nonexistent page. + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null + && manager.getNotificationChannel(channelId) == null + && manager.getNotificationChannel(channelId + SILENT_CHANNEL_SUFFIX) != null) { + channelId = channelId + SILENT_CHANNEL_SUFFIX; + } + intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_CHANNEL_ID, channelId); + // Below API 30 there's no per-conversation page; the channel's is the + // closest thing. + if (conversationId != null && Build.VERSION.SDK_INT >= 30) { + intent.putExtra(Settings.EXTRA_CONVERSATION_ID, conversationId); + } + } + intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); + // We may be starting from a non-activity context, so settings needs its own task. + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + return RESULT_OK; + } catch (ActivityNotFoundException e) { + return RESULT_NO_SETTINGS_ACTIVITY; + } catch (Throwable e) { + return RESULT_ERROR; + } + } + + // Creates (or refreshes the name/description of) the channel, returning the id to post under. + // Sound and importance live on the channel on Android, so a silent notification gets a quiet + // ".silent" variant of its channel instead of muting the whole channel. + private static String createChannel( + NotificationManager manager, + String channelId, + String channelName, + String channelDescription, + String[] channelGroup, + int importance, + boolean silent, + boolean bypassDnd) { + String effectiveId = silent ? channelId + SILENT_CHANNEL_SUFFIX : channelId; + String effectiveName = silent ? channelName + " (silent)" : channelName; + int androidImportance = silent + ? NotificationManager.IMPORTANCE_LOW + : mapImportance(importance); + + // Never change an existing channel's importance: createNotificationChannel can + // only lower it, permanently, and the user may have adjusted it themselves. + NotificationChannel existing = manager.getNotificationChannel(effectiveId); + if (existing != null) { + androidImportance = existing.getImportance(); + } + + NotificationChannel channel = + new NotificationChannel(effectiveId, effectiveName, androidImportance); + if (channelDescription != null) { + channel.setDescription(channelDescription); + } + if (channelGroup != null) { + // Re-creating the group is fine; it just refreshes the user-visible name. + manager.createNotificationChannelGroup( + new NotificationChannelGroup(channelGroup[0], channelGroup[1])); + channel.setGroup(channelGroup[0]); + } + if (silent) { + channel.setSound(null, null); + } + if (existing == null && bypassDnd) { + // Creation time only, like importance; existing channels keep their + // setting (the OS ignores this field for them anyway). Only takes + // effect if the user granted the app Do Not Disturb access. + channel.setBypassDnd(true); + } + manager.createNotificationChannel(channel); + return effectiveId; + } + + private static int mapImportance(int importance) { + switch (importance) { + case 0: return NotificationManager.IMPORTANCE_LOW; + case 2: return NotificationManager.IMPORTANCE_HIGH; + default: return NotificationManager.IMPORTANCE_DEFAULT; + } + } + + private static int smallIcon(Context context) { + int icon = context.getApplicationInfo().icon; + return icon != 0 ? icon : android.R.drawable.ic_dialog_info; + } + + private static void applyStyle(Notification.Builder builder, String body, String imagePath) { + if (imagePath != null) { + Bitmap bitmap = BitmapFactory.decodeFile(imagePath); + if (bitmap != null) { + builder.setStyle(new Notification.BigPictureStyle().bigPicture(bitmap)); + return; + } + // Couldn't decode the image; fall through and show the text alone. + } + if (body != null) { + builder.setStyle(new Notification.BigTextStyle().bigText(body)); + } + } + + // Conversation styling (API 28+): a MessagingStyle notification tied to the conversation. + private static void applyMessagingStyle( + Notification.Builder builder, + String[] conversation, + Icon conversationIcon, + boolean groupConversation, + String title, + String body, + String imagePath, + String[] messageSenders, + String[] messageTexts, + long[] messageTimestamps) { + // The style needs an "us" Person, but it's never rendered: we add no self-messages. + Person user = new Person.Builder().setName(conversation[1]).build(); + + Notification.MessagingStyle style = new Notification.MessagingStyle(user); + boolean history = messageSenders != null && messageTexts != null + && messageTimestamps != null && messageSenders.length > 0 + && messageSenders.length == messageTexts.length + && messageSenders.length == messageTimestamps.length; + if (history) { + // The conversation's accumulated messages, oldest first; the same + // sender name reuses one Person. + Map senders = new HashMap(); + for (int i = 0; i < messageSenders.length; i++) { + Person sender = senders.get(messageSenders[i]); + if (sender == null) { + Person.Builder person = new Person.Builder().setName(messageSenders[i]); + if (conversationIcon != null) { + person.setIcon(conversationIcon); + } + sender = person.build(); + senders.put(messageSenders[i], sender); + } + style.addMessage(messageTexts[i], messageTimestamps[i], sender); + } + } else { + // No history: just this notification's own message. + // The sender is whoever the title names, or the conversation itself. + Person.Builder sender = new Person.Builder() + .setName(title != null ? title : conversation[1]); + if (conversationIcon != null) { + sender.setIcon(conversationIcon); + } + style.addMessage(body != null ? body : "", System.currentTimeMillis(), sender.build()); + } + style.setGroupConversation(groupConversation); + if (groupConversation) { + style.setConversationTitle(conversation[1]); + } + builder.setStyle(style); + + // MessagingStyle and BigPictureStyle conflict, so the image rides as the large icon. + if (imagePath != null) { + Bitmap bitmap = BitmapFactory.decodeFile(imagePath); + if (bitmap != null) { + builder.setLargeIcon(bitmap); + } + } + } + + // Publishes (or refreshes) the long-lived shortcut that lets the system treat this as a + // real conversation. API 30+ only. + private static void publishConversationShortcut( + Context context, String[] conversation, Icon conversationIcon) { + try { + ShortcutManager shortcuts = context.getSystemService(ShortcutManager.class); + // A plain launch intent: opening the shortcut just opens the app, with none of our + // notification marker extras on it. + Intent launch = context.getPackageManager() + .getLaunchIntentForPackage(context.getPackageName()); + if (shortcuts == null || launch == null) { + return; + } + + Person.Builder person = new Person.Builder().setName(conversation[1]); + if (conversationIcon != null) { + person.setIcon(conversationIcon); + } + + ShortcutInfo.Builder shortcut = new ShortcutInfo.Builder(context, conversation[0]) + .setShortLabel(conversation[1]) + .setLongLived(true) + .setPerson(person.build()) + .setCategories(Collections.singleton(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)) + .setIntent(launch); + if (conversationIcon != null) { + shortcut.setIcon(conversationIcon); + } + // pushDynamicShortcut updates in place per id, and evicts old ones by rank when full. + shortcuts.pushDynamicShortcut(shortcut.build()); + } catch (Throwable e) { + // The shortcut can fail on its own (locked user, disabled restored shortcut, rate + // limits); losing the conversation-space treatment shouldn't sink the notification. + } + } + + private static Icon decodeIcon(String path) { + if (path == null) { + return null; + } + Bitmap bitmap = BitmapFactory.decodeFile(path); + // A broken icon shouldn't sink the whole notification; just go without. + return bitmap != null ? Icon.createWithBitmap(bitmap) : null; + } + + // Tapping the body (re-)opens the app's launcher activity; broadcast trampolines to an + // activity are banned since Android 12. The marker extras on the launch intent are what + // `maybeDeliverActivation` looks for. + private static void setContentIntent( + Context context, + Notification.Builder builder, + String id, + String[] metadataKeys, + String[] metadataValues) { + Intent launch = context.getPackageManager() + .getLaunchIntentForPackage(context.getPackageName()); + if (launch == null) { + return; + } + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_SINGLE_TOP + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + putInteractionExtras(launch, id, KIND_ACTIVATED, null, metadataKeys, metadataValues); + // One-shot token, so `maybeDeliverActivation` never fires twice for the same tap. + launch.putExtra(EXTRA_TOKEN, id + ":" + tokenCounter.incrementAndGet()); + // Unique data keeps launch intents of different notifications from filterEquals-matching + // (and so overwriting) each other. The component is explicit, so data doesn't affect + // where the intent goes. + launch.setData(Uri.parse(DATA_SCHEME + "://" + Uri.encode(id) + "/activated")); + + PendingIntent pending = PendingIntent.getActivity( + context, + requestCode(id, "activated"), + launch, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + builder.setContentIntent(pending); + } + + private static void addActions( + Context context, + Notification.Builder builder, + String id, + String[] metadataKeys, + String[] metadataValues, + String[] actionIds, + String[] actionTitles, + int[] actionKinds, + String[] actionPlaceholders) { + int count = actionIds == null ? 0 : actionIds.length; + for (int i = 0; i < count; i++) { + boolean reply = actionKinds[i] == 1; + PendingIntent pending = interactionBroadcast( + context, + id, + reply ? KIND_REPLY : KIND_ACTION, + actionIds[i], + metadataKeys, + metadataValues, + reply); + + Notification.Action.Builder action = + new Notification.Action.Builder((Icon) null, actionTitles[i], pending); + if (reply) { + String placeholder = actionPlaceholders != null && actionPlaceholders[i] != null + ? actionPlaceholders[i] + : actionTitles[i]; + action.addRemoteInput(new RemoteInput.Builder(REMOTE_INPUT_KEY) + .setLabel(placeholder) + .build()); + } + builder.addAction(action.build()); + } + } + + private static PendingIntent interactionBroadcast( + Context context, + String id, + int kind, + String actionId, + String[] metadataKeys, + String[] metadataValues, + boolean mutable) { + String discriminator = actionId != null ? "action:" + actionId : "kind:" + kind; + + // setPackage keeps this explicit enough for Android 14's implicit-PendingIntent ban. + Intent intent = new Intent(ACTION_INTERACTION); + intent.setPackage(context.getPackageName()); + // Unique data so intents for different (id, action) pairs never filterEquals-match, + // even if their hashCode-based request codes collide. + intent.setData(Uri.parse( + DATA_SCHEME + "://" + Uri.encode(id) + "/" + Uri.encode(discriminator))); + putInteractionExtras(intent, id, kind, actionId, metadataKeys, metadataValues); + + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (mutable) { + // RemoteInput needs a mutable PendingIntent on Android 12+, else it throws. + // Below 12 everything is mutable anyway. + if (Build.VERSION.SDK_INT >= 31) { + flags |= PendingIntent.FLAG_MUTABLE; + } + } else { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + + return PendingIntent.getBroadcast(context, requestCode(id, discriminator), intent, flags); + } + + private static void putInteractionExtras( + Intent intent, + String id, + int kind, + String actionId, + String[] metadataKeys, + String[] metadataValues) { + intent.putExtra(EXTRA_ID, id); + intent.putExtra(EXTRA_KIND, kind); + if (actionId != null) { + intent.putExtra(EXTRA_ACTION_ID, actionId); + } + intent.putExtra(EXTRA_METADATA_KEYS, metadataKeys); + intent.putExtra(EXTRA_METADATA_VALUES, metadataValues); + } + + // Unique per (notification id, action), so the PendingIntents of different buttons on the + // same notification don't collapse into one. + private static int requestCode(String id, String discriminator) { + return (id + "\u0000" + discriminator).hashCode(); + } + + private static synchronized void ensureReceiverRegistered(Context context) { + if (receiverRegistered) { + return; + } + Context app = context.getApplicationContext(); + IntentFilter filter = new IntentFilter(ACTION_INTERACTION); + // The broadcasts carry a data URI, so the filter has to match its scheme too. + filter.addDataScheme(DATA_SCHEME); + if (Build.VERSION.SDK_INT >= 33) { + app.registerReceiver(new InteractionReceiver(), filter, Context.RECEIVER_NOT_EXPORTED); + } else { + app.registerReceiver(new InteractionReceiver(), filter); + } + receiverRegistered = true; + } + + private static synchronized void ensureActivationWatcherRegistered(Activity activity) { + if (activationWatcherRegistered) { + return; + } + activity.getApplication().registerActivityLifecycleCallbacks(new ActivationWatcher()); + activationWatcherRegistered = true; + } + + // Delivers the Activated interaction if this activity was (re)opened by a notification tap. + // Removing the marker extra keeps it from firing again on the next resume, but that only + // edits our in-process copy: the system keeps the original extras, so we also skip + // history relaunches and already-consumed one-shot tokens. + // Known limitation: if the OS handed the tap to an already-alive activity via onNewIntent, + // getIntent() still returns the old intent unless the host activity calls setIntent(), + // so the app opens but the tap interaction may go unobserved. + static void maybeDeliverActivation(Activity activity) { + try { + Intent intent = activity.getIntent(); + if (intent == null || !intent.hasExtra(EXTRA_ID)) { + return; + } + // Relaunching from recents replays the old intent, stale extras and all. + if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) { + return; + } + String id = intent.getStringExtra(EXTRA_ID); + String token = intent.getStringExtra(EXTRA_TOKEN); + String[] keys = intent.getStringArrayExtra(EXTRA_METADATA_KEYS); + String[] values = intent.getStringArrayExtra(EXTRA_METADATA_VALUES); + intent.removeExtra(EXTRA_ID); + intent.removeExtra(EXTRA_TOKEN); + if (token == null || !consumedTokens.add(token)) { + // No token or one we've already delivered: not a fresh tap. + return; + } + if (id != null) { + rustInteractionCallback(id, KIND_ACTIVATED, null, null, keys, values); + // The tap auto-cancelled the notification; don't leave an orphan summary. + pruneAfterRemoval(activity, id); + } + } catch (Throwable ignored) { + } + } + + private static final class InteractionReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + try { + String id = intent.getStringExtra(EXTRA_ID); + int kind = intent.getIntExtra(EXTRA_KIND, -1); + if (id == null || kind < 0) { + return; + } + String actionId = intent.getStringExtra(EXTRA_ACTION_ID); + String[] keys = intent.getStringArrayExtra(EXTRA_METADATA_KEYS); + String[] values = intent.getStringArrayExtra(EXTRA_METADATA_VALUES); + + String replyText = null; + if (kind == KIND_REPLY) { + Bundle results = RemoteInput.getResultsFromIntent(intent); + CharSequence text = results != null + ? results.getCharSequence(REMOTE_INPUT_KEY) + : null; + if (text != null) { + replyText = text.toString(); + // A replied-to notification must be updated or removed, or its reply UI + // spins forever; we remove it. + NotificationManager manager = (NotificationManager) + context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.cancel(id, 0); + // Removing a group's last member orphans its summary. + pruneGroupSummaries(manager, id); + } + } else { + // No text came back; treat it as a plain button press. + kind = KIND_ACTION; + } + } + if (kind == KIND_DISMISSED) { + // The user swiped it away; same orphan-summary cleanup as above. + pruneAfterRemoval(context, id); + } + + rustInteractionCallback(id, kind, actionId, replyText, keys, values); + } catch (Throwable ignored) { + } + } + } + + private static final class ActivationWatcher implements Application.ActivityLifecycleCallbacks { + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + maybeDeliverActivation(activity); + } + + @Override + public void onActivityResumed(Activity activity) { + maybeDeliverActivation(activity); + } + + @Override public void onActivityStarted(Activity activity) {} + @Override public void onActivityPaused(Activity activity) {} + @Override public void onActivityStopped(Activity activity) {} + @Override public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} + @Override public void onActivityDestroyed(Activity activity) {} + } +} diff --git a/crates/notifications/src/sys/android/class.rs b/crates/notifications/src/sys/android/class.rs new file mode 100644 index 0000000..83ff3f0 --- /dev/null +++ b/crates/notifications/src/sys/android/class.rs @@ -0,0 +1,244 @@ +use std::sync::OnceLock; + +use jni::{ + objects::{GlobalRef, JClass, JObject, JObjectArray, JString, JValueGen}, + sys::{jboolean, jint, jlong}, + JNIEnv, NativeMethod, +}; + +use crate::{Interaction, InteractionKind, PermissionCallback, Result}; + +const NOTIFICATIONS_BYTECODE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/classes.dex")); + +// NOTE: This must be kept in sync with `Notifications.java`. +const INTERACTION_CALLBACK_NAME: &str = "rustInteractionCallback"; +// NOTE: This must be kept in sync with the signature of `rust_interaction_callback`, +// and the signature specified in `Notifications.java`. +const INTERACTION_CALLBACK_SIGNATURE: &str = + "(Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;)V"; + +// NOTE: This must be kept in sync with `NotificationPermissionFragment.java`. +const PERMISSION_CALLBACK_NAME: &str = "rustPermissionCallback"; +// NOTE: This must be kept in sync with the signature of `rust_permission_callback`, +// and the signature specified in `NotificationPermissionFragment.java`. +const PERMISSION_CALLBACK_SIGNATURE: &str = "(JZ)V"; + +// Interaction kinds passed in from Java; must match the `KIND_*` constants in `Notifications.java`. +const KIND_ACTIVATED: jint = 0; +const KIND_DISMISSED: jint = 1; +const KIND_ACTION: jint = 2; +const KIND_REPLY: jint = 3; + +// NOTE: The signature of this function must be kept in sync with +// `INTERACTION_CALLBACK_SIGNATURE` above. +unsafe extern "C" fn rust_interaction_callback<'a>( + mut env: JNIEnv<'a>, + _: JClass<'a>, + notification_id: JString<'a>, + kind: jint, + action_id: JString<'a>, + reply_text: JString<'a>, + metadata_keys: JObjectArray<'a>, + metadata_values: JObjectArray<'a>, +) { + // Unwinding into the JVM would abort the process, so nothing below is + // allowed to escape as a panic. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + // Read all JNI data up front, while we still hold the env on the Java + // callback thread (usually the Android main thread). + let Some(notification_id) = optional_jstring(&mut env, ¬ification_id) else { + return; + }; + let kind = match kind { + KIND_ACTIVATED => InteractionKind::Activated, + KIND_DISMISSED => InteractionKind::Dismissed, + KIND_ACTION => InteractionKind::Action { + id: optional_jstring(&mut env, &action_id).unwrap_or_default(), + }, + KIND_REPLY => InteractionKind::Reply { + action_id: optional_jstring(&mut env, &action_id).unwrap_or_default(), + text: optional_jstring(&mut env, &reply_text).unwrap_or_default(), + }, + _ => return, + }; + let metadata = read_metadata(&mut env, &metadata_keys, &metadata_values); + + let interaction = Interaction { + notification_id, + kind, + metadata, + }; + + // Hand it to the app on a background thread, so its handler can block + // without freezing the Android main thread. + std::thread::spawn(move || crate::deliver_interaction(interaction)); + })); +} + +// NOTE: The signature of this function must be kept in sync with +// `PERMISSION_CALLBACK_SIGNATURE` above. +unsafe extern "C" fn rust_permission_callback<'a>( + _env: JNIEnv<'a>, + _: JClass<'a>, + callback_ptr: jlong, + granted: jboolean, +) { + // As above: never unwind into the JVM. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let callback_ptr = callback_ptr as *mut PermissionCallback; + if callback_ptr.is_null() { + return; + } + + // SAFETY: This pointer was created by `Box::into_raw` in `request_permission`. + // The Java side invokes this callback exactly once. + let callback = *unsafe { Box::from_raw(callback_ptr) }; + + // Run the app's callback on a background thread, so it can block if needed + // without freezing the Android main thread. + std::thread::spawn(move || callback(Ok(granted != 0))); + })); +} + +fn optional_jstring(env: &mut JNIEnv<'_>, value: &JString<'_>) -> Option { + if value.as_raw().is_null() { + return None; + } + env.get_string(value).ok().map(String::from) +} + +/// Reads the parallel key/value arrays back into metadata pairs. +fn read_metadata( + env: &mut JNIEnv<'_>, + keys: &JObjectArray<'_>, + values: &JObjectArray<'_>, +) -> Vec<(String, String)> { + if keys.as_raw().is_null() || values.as_raw().is_null() { + return Vec::new(); + } + let count = match (env.get_array_length(keys), env.get_array_length(values)) { + (Ok(keys_len), Ok(values_len)) => keys_len.min(values_len), + _ => return Vec::new(), + }; + + let mut metadata = Vec::with_capacity(count as usize); + for index in 0..count { + let pair = env.get_object_array_element(keys, index).and_then(|key| { + env.get_object_array_element(values, index) + .map(|value| (key, value)) + }); + let Ok((key, value)) = pair else { + let _ = env.exception_clear(); + return metadata; + }; + let key = optional_jstring(env, &JString::from(key)); + let value = optional_jstring(env, &JString::from(value)); + if let (Some(key), Some(value)) = (key, value) { + metadata.push((key, value)); + } + } + metadata +} + +static NOTIFICATIONS_CLASS: OnceLock = OnceLock::new(); +static PERMISSION_FRAGMENT_CLASS: OnceLock = OnceLock::new(); + +pub(super) fn get_notifications_class(env: &mut JNIEnv<'_>) -> Result<&'static GlobalRef> { + load_classes(env)?; + Ok(NOTIFICATIONS_CLASS.get().expect("set by load_classes")) +} + +pub(super) fn get_permission_fragment_class(env: &mut JNIEnv<'_>) -> Result<&'static GlobalRef> { + load_classes(env)?; + Ok(PERMISSION_FRAGMENT_CLASS.get().expect("set by load_classes")) +} + +/// Loads both Java classes from one dex loader (so they share a defining class +/// loader) and registers their Rust native methods. +fn load_classes(env: &mut JNIEnv<'_>) -> Result<()> { + if NOTIFICATIONS_CLASS.get().is_some() && PERMISSION_FRAGMENT_CLASS.get().is_some() { + return Ok(()); + } + + let loader = dex_class_loader(env)?; + + let notifications = load_class(env, &loader, "robius.notifications.Notifications")?; + register_native_method( + env, + ¬ifications, + INTERACTION_CALLBACK_NAME, + INTERACTION_CALLBACK_SIGNATURE, + rust_interaction_callback as *mut _, + )?; + let notifications = env.new_global_ref(notifications)?; + + let fragment = load_class( + env, + &loader, + "robius.notifications.NotificationPermissionFragment", + )?; + register_native_method( + env, + &fragment, + PERMISSION_CALLBACK_NAME, + PERMISSION_CALLBACK_SIGNATURE, + rust_permission_callback as *mut _, + )?; + let fragment = env.new_global_ref(fragment)?; + + // If another thread won the race, its classes are just as good as ours. + let _ = NOTIFICATIONS_CLASS.set(notifications); + let _ = PERMISSION_FRAGMENT_CLASS.set(fragment); + Ok(()) +} + +fn register_native_method<'a>( + env: &mut JNIEnv<'a>, + class: &JClass<'a>, + name: &str, + signature: &str, + fn_ptr: *mut std::ffi::c_void, +) -> Result<()> { + env.register_native_methods( + class, + &[NativeMethod { + name: name.into(), + sig: signature.into(), + fn_ptr, + }], + ) + .map_err(|e| e.into()) +} + +fn dex_class_loader<'a>(env: &mut JNIEnv<'a>) -> Result> { + const IN_MEMORY_LOADER: &str = "dalvik/system/InMemoryDexClassLoader"; + + let byte_buffer = unsafe { + env.new_direct_byte_buffer( + NOTIFICATIONS_BYTECODE.as_ptr() as *mut u8, + NOTIFICATIONS_BYTECODE.len(), + ) + }?; + + Ok(env.new_object( + IN_MEMORY_LOADER, + "(Ljava/nio/ByteBuffer;Ljava/lang/ClassLoader;)V", + &[ + JValueGen::Object(&JObject::from(byte_buffer)), + JValueGen::Object(&JObject::null()), + ], + )?) +} + +fn load_class<'a>(env: &mut JNIEnv<'a>, loader: &JObject<'_>, name: &str) -> Result> { + let name = env.new_string(name)?; + Ok(env + .call_method( + loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValueGen::Object(&JObject::from(name))], + )? + .l()? + .into()) +} diff --git a/crates/notifications/src/sys/android/mod.rs b/crates/notifications/src/sys/android/mod.rs new file mode 100644 index 0000000..0052d55 --- /dev/null +++ b/crates/notifications/src/sys/android/mod.rs @@ -0,0 +1,685 @@ +mod class; + +use std::time::SystemTime; + +use jni::{ + objects::{JIntArray, JLongArray, JObject, JObjectArray, JString, JValueGen}, + sys::{jint, jlong}, + JNIEnv, +}; + +use crate::{ + ActionKind, ActiveIdsCallback, Error, LockScreenVisibility, NotificationOptions, + NotificationSettings, PermissionCallback, Progress, Result, SettingsCallback, SettingsScope, + Sound, Urgency, +}; + +/// Android has no OS-side scheduling here; lib.rs runs the fallback timer, so +/// `show` never sees a future `scheduled_time` and can ignore the field. +pub(crate) const NATIVE_SCHEDULING: bool = false; + +// Result codes returned by the Java side; must match `Notifications.java`. +const RESULT_OK: i32 = 0; +const RESULT_PERMISSION_DENIED: i32 = 1; + +// Channels used for notifications that didn't set one. Importance sticks to a +// channel once created, so each urgency gets its own default channel. +const DEFAULT_CHANNEL_QUIET: (&str, &str) = + ("robius.notifications.default.quiet", "Quiet notifications"); +const DEFAULT_CHANNEL_NORMAL: (&str, &str) = ("robius.notifications.default", "Notifications"); +const DEFAULT_CHANNEL_URGENT: (&str, &str) = + ("robius.notifications.default.urgent", "Urgent notifications"); + +pub(crate) fn show(options: NotificationOptions) -> Result<()> { + // Catch a missing image file up front, with a useful I/O error. + if let Some(image) = &options.image { + std::fs::metadata(image)?; + } + + robius_android_env::with_activity(|env, activity| { + // The thread stays attached forever, so free our local refs via a frame + // or they pile up until ART aborts. + env.with_local_frame(64, |env| show_inner(env, activity, &options, false)) + }) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x) +} + +pub(crate) fn update_progress(options: &NotificationOptions) -> Result<()> { + // The show path with the update-only flag: the same tag replaces the old + // notification quietly (progress sets only-alert-once in Java), and Java + // drops the post entirely if the user already dismissed it. + robius_android_env::with_activity(|env, activity| { + // Local frame: see `show`. + env.with_local_frame(64, |env| show_inner(env, activity, options, true)) + }) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x) +} + +pub(crate) fn cancel(id: &str) -> Result<()> { + robius_android_env::with_activity(|env, activity| { + // Local frame: see `show`. + env.with_local_frame(16, |env| { + let class = class::get_notifications_class(env)?; + let id = env.new_string(id)?; + let result = env + .call_static_method( + class, + "cancel", + "(Landroid/content/Context;Ljava/lang/String;)I", + &[JValueGen::Object(activity), JValueGen::Object(id.as_ref())], + ) + .map_err(|e| map_jni_error(env, e))? + .i()?; + check_result(result) + }) + }) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x) +} + +pub(crate) fn cancel_all() -> Result<()> { + robius_android_env::with_activity(|env, activity| { + // Local frame: see `show`. + env.with_local_frame(16, |env| { + let class = class::get_notifications_class(env)?; + let result = env + .call_static_method( + class, + "cancelAll", + "(Landroid/content/Context;)I", + &[JValueGen::Object(activity)], + ) + .map_err(|e| map_jni_error(env, e))? + .i()?; + check_result(result) + }) + }) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x) +} + +pub(crate) fn request_permission(callback: PermissionCallback, provisional: bool) -> Result<()> { + // Android has no quiet-delivery permission: a provisional request just + // reports the standing state, without ever showing the prompt fragment. + if provisional { + return report_permission_state(callback); + } + + let callback_ptr = Box::into_raw(Box::new(callback)); + + let result = robius_android_env::with_activity(|env, activity| { + // Local frame: see `show`. + env.with_local_frame(16, |env| -> Result<()> { + let class = class::get_permission_fragment_class(env)?; + env.call_static_method( + class, + "request", + "(Landroid/app/Activity;J)V", + &[ + JValueGen::Object(activity), + JValueGen::Long(callback_ptr as jlong), + ], + ) + .map_err(|e| map_jni_error(env, e))?; + Ok(()) + }) + }); + + // Once the Java call went through, Java owns the pointer and delivers the + // callback exactly once. On failure Java never got it, so free it here. + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => { + // SAFETY: `callback_ptr` came from `Box::into_raw` above and Java never got it. + let _ = unsafe { Box::from_raw(callback_ptr) }; + Err(error) + } + Err(_) => { + // SAFETY: same as above. + let _ = unsafe { Box::from_raw(callback_ptr) }; + Err(Error::AndroidEnvironment) + } + } +} + +/// Reports the standing permission state to the callback, without prompting. +fn report_permission_state(callback: PermissionCallback) -> Result<()> { + let granted = robius_android_env::with_activity(|env, activity| { + // Local frame: see `show`. + env.with_local_frame(16, |env| -> Result { + let class = class::get_permission_fragment_class(env)?; + Ok(env + .call_static_method( + class, + "currentPermissionState", + "(Landroid/content/Context;)Z", + &[JValueGen::Object(activity)], + ) + .map_err(|e| map_jni_error(env, e))? + .z()?) + }) + }) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x)?; + + callback(Ok(granted)); + Ok(()) +} + +pub(crate) fn set_app_badge(_count: u32) -> Result<()> { + // Launcher badges follow the app's active notifications on Android. + Ok(()) +} + +pub(crate) fn active_notification_ids(callback: ActiveIdsCallback) -> Result<()> { + // The whole query is synchronous, so the callback runs before we return. + let ids = robius_android_env::with_activity(|env, activity| { + // Local frame: see `show`. + env.with_local_frame(16, |env| query_active_ids(env, activity)) + }) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x)?; + + callback(Ok(ids)); + Ok(()) +} + +fn query_active_ids(env: &mut JNIEnv<'_>, activity: &JObject<'_>) -> Result> { + let class = class::get_notifications_class(env)?; + let array = env + .call_static_method( + class, + "activeNotificationIds", + "(Landroid/content/Context;)[Ljava/lang/String;", + &[JValueGen::Object(activity)], + ) + .map_err(|e| map_jni_error(env, e))? + .l()?; + // The Java side returns null when the query failed. + if array.as_raw().is_null() { + return Err(Error::Unknown); + } + + let array = JObjectArray::from(array); + let count = env + .get_array_length(&array) + .map_err(|e| map_jni_error(env, e))?; + let mut ids = Vec::with_capacity(count as usize); + for index in 0..count { + let element = env + .get_object_array_element(&array, index) + .map_err(|e| map_jni_error(env, e))?; + // Java never puts nulls in the array, but don't crash if it did. + if element.as_raw().is_null() { + continue; + } + let element = JString::from(element); + let id = match env.get_string(&element) { + Ok(id) => String::from(id), + Err(e) => return Err(map_jni_error(env, e)), + }; + // Drop each element's ref right away, so big lists can't blow the ref table. + env.delete_local_ref(element)?; + ids.push(id); + } + Ok(ids) +} + +pub(crate) fn init_interaction_listener() -> Result<()> { + robius_android_env::with_activity(|env, activity| { + // Local frame: see `show`. + env.with_local_frame(16, |env| { + let class = class::get_notifications_class(env)?; + let result = env + .call_static_method( + class, + "initListener", + "(Landroid/app/Activity;)I", + &[JValueGen::Object(activity)], + ) + .map_err(|e| map_jni_error(env, e))? + .i()?; + check_result(result) + }) + }) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x) +} + +pub(crate) fn notification_settings(scope: SettingsScope, callback: SettingsCallback) -> Result<()> { + // The whole query is synchronous, so the callback runs before we return. + let settings = robius_android_env::with_activity(|env, activity| { + // Local frame: see `show`. + env.with_local_frame(16, |env| query_settings(env, activity, &scope)) + }) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x)?; + + callback(Ok(settings)); + Ok(()) +} + +fn query_settings( + env: &mut JNIEnv<'_>, + activity: &JObject<'_>, + scope: &SettingsScope, +) -> Result { + let class = class::get_notifications_class(env)?; + let (channel_id, conversation_id) = scope_ids(scope); + let channel_id = optional_string(env, channel_id)?; + let conversation_id = optional_string(env, conversation_id)?; + let null = JObject::null(); + let channel_id = channel_id.as_ref().map(JString::as_ref).unwrap_or(&null); + let conversation_id = conversation_id.as_ref().map(JString::as_ref).unwrap_or(&null); + + let array = env + .call_static_method( + class, + "notificationSettings", + "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)[I", + &[ + JValueGen::Object(activity), + JValueGen::Object(channel_id), + JValueGen::Object(conversation_id), + ], + ) + .map_err(|e| map_jni_error(env, e))? + .l()?; + // The Java side returns null when the query failed. + if array.as_raw().is_null() { + return Err(Error::Unknown); + } + + // [enabled, urgency, sound, badge, customized, priority], -1 = unknown; + // must be kept in sync with `Notifications.java`. + let mut values: [jint; 6] = [-1; 6]; + env.get_int_array_region(JIntArray::from(array), 0, &mut values) + .map_err(|e| map_jni_error(env, e))?; + + Ok(NotificationSettings { + enabled: values[0] == 1, + urgency: match values[1] { + 0 => Some(Urgency::Low), + 1 => Some(Urgency::Normal), + 2 => Some(Urgency::Critical), + _ => None, + }, + sound_enabled: settings_bool(values[2]), + badge_enabled: settings_bool(values[3]), + customized_by_user: settings_bool(values[4]), + priority_conversation: settings_bool(values[5]), + }) +} + +pub(crate) fn open_notification_settings(scope: SettingsScope) -> Result<()> { + let (channel_id, conversation_id) = scope_ids(&scope); + robius_android_env::with_activity(|env, activity| { + // Local frame: see `show`. + env.with_local_frame(16, |env| { + let class = class::get_notifications_class(env)?; + let channel_id = optional_string(env, channel_id)?; + let conversation_id = optional_string(env, conversation_id)?; + let null = JObject::null(); + let channel_id = channel_id.as_ref().map(JString::as_ref).unwrap_or(&null); + let conversation_id = conversation_id.as_ref().map(JString::as_ref).unwrap_or(&null); + let result = env + .call_static_method( + class, + "openSettings", + "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)I", + &[ + JValueGen::Object(activity), + JValueGen::Object(channel_id), + JValueGen::Object(conversation_id), + ], + ) + .map_err(|e| map_jni_error(env, e))? + .i()?; + check_result(result) + }) + }) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x) +} + +/// The (channel id, conversation id) pair a scope passes to the Java side. +fn scope_ids(scope: &SettingsScope) -> (Option<&str>, Option<&str>) { + match scope { + SettingsScope::App => (None, None), + SettingsScope::Channel { channel_id } => (Some(channel_id), None), + SettingsScope::Conversation { + channel_id, + conversation_id, + } => (Some(channel_id), Some(conversation_id)), + } +} + +/// Turns a Java-side settings value into an optional bool (-1 = unknown). +fn settings_bool(value: jint) -> Option { + match value { + 0 => Some(false), + 1 => Some(true), + _ => None, + } +} + +fn show_inner( + env: &mut JNIEnv<'_>, + activity: &JObject<'_>, + options: &NotificationOptions, + update_only: bool, +) -> Result<()> { + let class = class::get_notifications_class(env)?; + + // Importance (and sound) live on the channel on Android. An explicit channel + // brings its own importance; the default channel takes it from the urgency. + let (channel_id, channel_name, channel_description, importance) = match &options.channel { + Some(channel) => ( + channel.id.as_str(), + channel.name.as_str(), + channel.description.as_deref(), + channel.importance, + ), + None => { + let urgency = options.urgency.unwrap_or_default(); + let (id, name) = match urgency { + Urgency::Low => DEFAULT_CHANNEL_QUIET, + Urgency::Normal => DEFAULT_CHANNEL_NORMAL, + Urgency::Critical => DEFAULT_CHANNEL_URGENT, + }; + (id, name, None, urgency) + } + }; + let importance: jint = match importance { + Urgency::Low => 0, + Urgency::Normal => 1, + Urgency::Critical => 2, + }; + // Sound is per-channel on Android, so a named per-notification sound has no + // clean mapping; it falls back to the channel's default sound. + let silent = matches!(options.sound, Some(Sound::Silent)); + + // The path was checked in `show`, so a non-UTF-8 path is the only way to fail here. + let image = options + .image + .as_deref() + .map(|path| path.to_str().ok_or(Error::InvalidNotification)) + .transpose()?; + + // The channel's group crosses JNI as a {group id, group name} pair. + let channel_group: Vec<&str> = options + .channel + .as_ref() + .and_then(|channel| channel.group.as_ref()) + .map(|(group_id, group_name)| vec![group_id.as_str(), group_name.as_str()]) + .unwrap_or_default(); + + // The conversation crosses as {id, name, icon path or null}. + let conversation: Vec> = match &options.conversation { + Some(conversation) => vec![ + Some(conversation.id.as_str()), + Some(conversation.name.as_str()), + conversation + .icon + .as_deref() + .map(|path| path.to_str().ok_or(Error::InvalidNotification)) + .transpose()?, + ], + None => Vec::new(), + }; + let group_conversation = options + .conversation + .as_ref() + .is_some_and(|conversation| conversation.group_conversation); + + // The conversation's history crosses as parallel {sender, text, timestamp} arrays. + let message_senders: Vec<&str> = options + .conversation_messages + .iter() + .map(|message| message.sender.as_str()) + .collect(); + let message_texts: Vec<&str> = options + .conversation_messages + .iter() + .map(|message| message.text.as_str()) + .collect(); + let message_timestamps: Vec = options + .conversation_messages + .iter() + .map(|message| message.timestamp_ms.min(jlong::MAX as u64) as jlong) + .collect(); + + // -1 = no progress, 0 = indeterminate, > 0 = determinate; matches `Notifications.java`. + let (progress_current, progress_total): (jint, jint) = match options.progress { + None => (0, -1), + Some(Progress::Indeterminate) => (0, 0), + Some(Progress::Determinate { current, total }) => { + let total = total.min(jint::MAX as u32); + // An overshooting `current` just means done. + (current.min(total) as jint, total as jint) + } + }; + + // Event timestamp in ms since epoch, -1 when unset (pre-epoch clamps to 0). + let when_ms: jlong = options + .timestamp + .map(|time| { + time.duration_since(SystemTime::UNIX_EPOCH) + .map(|since| since.as_millis().min(jlong::MAX as u128) as jlong) + .unwrap_or(0) + }) + .unwrap_or(-1); + + // -2 = unset; the codes must match the switch in `Notifications.java`. + let visibility: jint = match options.lock_screen_visibility { + None => -2, + Some(LockScreenVisibility::Public) => 0, + Some(LockScreenVisibility::Private) => 1, + Some(LockScreenVisibility::Secret) => 2, + }; + + let metadata_keys: Vec<&str> = options.metadata.iter().map(|(key, _)| key.as_str()).collect(); + let metadata_values: Vec<&str> = options.metadata.iter().map(|(_, value)| value.as_str()).collect(); + + let action_ids: Vec<&str> = options.actions.iter().map(|action| action.id.as_str()).collect(); + let action_titles: Vec<&str> = options.actions.iter().map(|action| action.title.as_str()).collect(); + let action_kinds: Vec = options + .actions + .iter() + .map(|action| match action.kind { + ActionKind::Button => 0, + ActionKind::Reply => 1, + }) + .collect(); + let action_placeholders: Vec> = options + .actions + .iter() + .map(|action| action.placeholder.as_deref()) + .collect(); + + let badge_count: jint = options + .badge_count + .map(|count| count.min(jint::MAX as u32) as jint) + .unwrap_or(-1); + + let id = env.new_string(&options.id)?; + let title = optional_string(env, options.title.as_deref())?; + let body = optional_string(env, options.body.as_deref())?; + let subtitle = optional_string(env, options.subtitle.as_deref())?; + let channel_id = env.new_string(channel_id)?; + let channel_name = env.new_string(channel_name)?; + let channel_description = optional_string(env, channel_description)?; + let channel_group = string_array(env, &channel_group)?; + let group = optional_string(env, options.group.as_deref())?; + let image = optional_string(env, image)?; + let conversation = optional_string_array(env, &conversation)?; + let message_senders = string_array(env, &message_senders)?; + let message_texts = string_array(env, &message_texts)?; + let message_timestamps = long_array(env, &message_timestamps)?; + let metadata_keys = string_array(env, &metadata_keys)?; + let metadata_values = string_array(env, &metadata_values)?; + let action_ids = string_array(env, &action_ids)?; + let action_titles = string_array(env, &action_titles)?; + let action_kinds = int_array(env, &action_kinds)?; + let action_placeholders = optional_string_array(env, &action_placeholders)?; + + let null = JObject::null(); + let title = title.as_ref().map(JString::as_ref).unwrap_or(&null); + let body = body.as_ref().map(JString::as_ref).unwrap_or(&null); + let subtitle = subtitle.as_ref().map(JString::as_ref).unwrap_or(&null); + let channel_description = channel_description + .as_ref() + .map(JString::as_ref) + .unwrap_or(&null); + let channel_group = channel_group.as_ref().map(JObjectArray::as_ref).unwrap_or(&null); + let group = group.as_ref().map(JString::as_ref).unwrap_or(&null); + let image = image.as_ref().map(JString::as_ref).unwrap_or(&null); + let conversation = conversation.as_ref().map(JObjectArray::as_ref).unwrap_or(&null); + let message_senders = message_senders.as_ref().map(JObjectArray::as_ref).unwrap_or(&null); + let message_texts = message_texts.as_ref().map(JObjectArray::as_ref).unwrap_or(&null); + let message_timestamps = message_timestamps + .as_ref() + .map(JLongArray::as_ref) + .unwrap_or(&null); + let metadata_keys = metadata_keys.as_ref().map(JObjectArray::as_ref).unwrap_or(&null); + let metadata_values = metadata_values.as_ref().map(JObjectArray::as_ref).unwrap_or(&null); + let action_ids = action_ids.as_ref().map(JObjectArray::as_ref).unwrap_or(&null); + let action_titles = action_titles.as_ref().map(JObjectArray::as_ref).unwrap_or(&null); + let action_kinds = action_kinds.as_ref().map(JIntArray::as_ref).unwrap_or(&null); + let action_placeholders = action_placeholders + .as_ref() + .map(JObjectArray::as_ref) + .unwrap_or(&null); + + let result = env + .call_static_method( + class, + "show", + "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;\ + Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;\ + [Ljava/lang/String;IZZILjava/lang/String;Ljava/lang/String;[Ljava/lang/String;Z\ + [Ljava/lang/String;[Ljava/lang/String;[JIIJZI\ + [Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;[Ljava/lang/String;\ + [I[Ljava/lang/String;Z)I", + &[ + JValueGen::Object(activity), + JValueGen::Object(id.as_ref()), + JValueGen::Object(title), + JValueGen::Object(body), + JValueGen::Object(subtitle), + JValueGen::Object(channel_id.as_ref()), + JValueGen::Object(channel_name.as_ref()), + JValueGen::Object(channel_description), + JValueGen::Object(channel_group), + JValueGen::Int(importance), + JValueGen::Bool(silent as u8), + JValueGen::Bool(options.bypass_dnd as u8), + JValueGen::Int(badge_count), + JValueGen::Object(group), + JValueGen::Object(image), + JValueGen::Object(conversation), + JValueGen::Bool(group_conversation as u8), + JValueGen::Object(message_senders), + JValueGen::Object(message_texts), + JValueGen::Object(message_timestamps), + JValueGen::Int(progress_current), + JValueGen::Int(progress_total), + JValueGen::Long(when_ms), + JValueGen::Bool(options.persistent as u8), + JValueGen::Int(visibility), + JValueGen::Object(metadata_keys), + JValueGen::Object(metadata_values), + JValueGen::Object(action_ids), + JValueGen::Object(action_titles), + JValueGen::Object(action_kinds), + JValueGen::Object(action_placeholders), + JValueGen::Bool(update_only as u8), + ], + ) + .map_err(|e| map_jni_error(env, e))? + .i()?; + + check_result(result) +} + +/// Maps a result code from the Java side into our `Result`. +fn check_result(result: i32) -> Result<()> { + match result { + RESULT_OK => Ok(()), + RESULT_PERMISSION_DENIED => Err(Error::PermissionDenied), + _ => Err(Error::Unknown), + } +} + +/// Turns a JNI error into our [`Error`], clearing any pending Java exception +/// first - a leftover exception would break the next JNI call. +fn map_jni_error(env: &mut JNIEnv<'_>, error: jni::errors::Error) -> Error { + if matches!(error, jni::errors::Error::JavaException) { + let _ = env.exception_clear(); + } + error.into() +} + +fn optional_string<'a>(env: &mut JNIEnv<'a>, text: Option<&str>) -> Result>> { + text.map(|text| env.new_string(text).map_err(Error::from)) + .transpose() +} + +fn string_array<'a>(env: &mut JNIEnv<'a>, strings: &[&str]) -> Result>> { + if strings.is_empty() { + return Ok(None); + } + + let array = env.new_object_array(strings.len() as i32, "java/lang/String", JObject::null())?; + for (index, string) in strings.iter().enumerate() { + let string = env.new_string(string)?; + env.set_object_array_element(&array, index as i32, &string)?; + // Drop each element's ref right away, so big arrays can't blow the ref table. + env.delete_local_ref(string)?; + } + + Ok(Some(array)) +} + +fn optional_string_array<'a>( + env: &mut JNIEnv<'a>, + strings: &[Option<&str>], +) -> Result>> { + if strings.is_empty() { + return Ok(None); + } + + let array = env.new_object_array(strings.len() as i32, "java/lang/String", JObject::null())?; + for (index, string) in strings.iter().enumerate() { + if let Some(string) = string { + let string = env.new_string(string)?; + env.set_object_array_element(&array, index as i32, &string)?; + // Same as in `string_array`: don't hold refs for the whole call. + env.delete_local_ref(string)?; + } + } + + Ok(Some(array)) +} + +fn int_array<'a>(env: &mut JNIEnv<'a>, values: &[jint]) -> Result>> { + if values.is_empty() { + return Ok(None); + } + + let array = env.new_int_array(values.len() as i32)?; + env.set_int_array_region(&array, 0, values)?; + Ok(Some(array)) +} + +fn long_array<'a>(env: &mut JNIEnv<'a>, values: &[jlong]) -> Result>> { + if values.is_empty() { + return Ok(None); + } + + let array = env.new_long_array(values.len() as i32)?; + env.set_long_array_region(&array, 0, values)?; + Ok(Some(array)) +} diff --git a/crates/notifications/src/sys/apple/communication.rs b/crates/notifications/src/sys/apple/communication.rs new file mode 100644 index 0000000..b166dab --- /dev/null +++ b/crates/notifications/src/sys/apple/communication.rs @@ -0,0 +1,165 @@ +//! Apple communication notifications (the `apple-communication` cargo feature): +//! renders a conversation notification as a real person-to-person message, +//! with the sender's (or group's) avatar as the icon, and donates the intent +//! for Siri suggestions and Focus. (Focus's per-contact "allowed people" +//! breakthrough needs contact details our Conversation doesn't carry, so the +//! upgrade here is the rendering + donations, not contact matching.) +//! +//! Works by describing the message as an `INSendMessageIntent` (Intents +//! framework), donating it, and asking UserNotifications to re-render the +//! content from it. Needs iOS 15+/macOS 12+, the +//! `com.apple.developer.usernotifications.communication` entitlement, and +//! `INSendMessageIntent` listed in the app's Info.plist `NSUserActivityTypes` +//! (donations fail silently without that); anything short of the entitlement +//! quietly falls back to the plain rendering. + +use objc2::{msg_send, rc::Retained, sel, AnyThread}; +use objc2_foundation::{NSArray, NSData, NSError, NSObjectProtocol, NSString}; +use objc2_intents::{ + INImage, INInteraction, INInteractionDirection, INOutgoingMessageType, INPerson, + INPersonHandle, INPersonHandleType, INSendMessageIntent, INSpeakableString, +}; +use objc2_user_notifications::{UNMutableNotificationContent, UNNotificationContent}; + +use crate::{Conversation, NotificationOptions}; + +/// Re-renders `content` as a communication notification, or `None` if the +/// system can't (old OS) or won't (no entitlement) do it. +pub(super) fn enrich( + content: &UNMutableNotificationContent, + options: &NotificationOptions, +) -> Option> { + let conversation = options.conversation.as_ref()?; + // The re-render call only exists on iOS 15+/macOS 12+. + if !content.respondsToSelector(sel!(contentByUpdatingWithProvider:error:)) { + return None; + } + + let sender = sender_person(options, conversation); + // A spoken/display name for group chats; 1:1 chats go by the sender. + let group_name = conversation.group_conversation.then(|| unsafe { + INSpeakableString::initWithSpokenPhrase( + INSpeakableString::alloc(), + &NSString::from_str(&conversation.name), + ) + }); + // The system only treats the intent as a group message (and shows the + // group name) when it carries multiple recipients. We don't know the + // actual members, so hand it two nameless placeholders. + let recipients = conversation.group_conversation.then(|| { + NSArray::from_retained_slice(&[ + placeholder_person(conversation, 1), + placeholder_person(conversation, 2), + ]) + }); + let body = options.body.as_deref().map(NSString::from_str); + + // An incoming message in this conversation, from this sender. + let intent = unsafe { + INSendMessageIntent::initWithRecipients_outgoingMessageType_content_speakableGroupName_conversationIdentifier_serviceName_sender_attachments( + INSendMessageIntent::alloc(), + recipients.as_deref(), + INOutgoingMessageType::OutgoingMessageText, + body.as_deref(), + group_name.as_deref(), + Some(&NSString::from_str(&conversation.id)), + None, + Some(&sender), + None, + ) + }; + // A group chat's avatar hangs off the group-name parameter, not the sender. + if conversation.group_conversation { + if let Some(image) = conversation_image(conversation) { + unsafe { + intent.setImage_forParameterNamed( + Some(&image), + &NSString::from_str("speakableGroupName"), + ); + } + } + } + + // Donating powers Focus's "allowed people" and Siri suggestions; the + // notification renders fine even if the donation itself fails. + unsafe { + let interaction = + INInteraction::initWithIntent_response(INInteraction::alloc(), &intent, None); + interaction.setDirection(INInteractionDirection::Incoming); + interaction.donateInteractionWithCompletion(None); + } + + // The typed `INSendMessageIntent: UNNotificationContentProviding` + // conformance lives in a cross-framework category the bindings don't + // model, so this one call goes through a raw selector. + let enriched: Result, Retained> = + unsafe { msg_send![content, contentByUpdatingWithProvider: &*intent, error: _] }; + // The usual error here is the missing communication entitlement. + enriched.ok() +} + +/// The conversation's icon as an `INImage`, if it has one that loads. +fn conversation_image(conversation: &Conversation) -> Option> { + conversation + .icon + .as_deref() + .and_then(|path| std::fs::read(path).ok()) + .map(|bytes| unsafe { INImage::imageWithImageData(&NSData::with_bytes(&bytes)) }) +} + +/// A nameless stand-in group member; see the recipients comment in [`enrich`]. +fn placeholder_person(conversation: &Conversation, n: u32) -> Retained { + let value = NSString::from_str(&format!("{}#member-{n}", conversation.id)); + let handle = unsafe { + INPersonHandle::initWithValue_type( + INPersonHandle::alloc(), + Some(&value), + INPersonHandleType::Unknown, + ) + }; + unsafe { + INPerson::initWithPersonHandle_nameComponents_displayName_image_contactIdentifier_customIdentifier( + INPerson::alloc(), + &handle, + None, + None, + None, + None, + Some(&value), + ) + } +} + +/// The message's sender: named like the conversation history's sender +/// (the notification title, else the conversation name), with the +/// conversation's icon as their avatar. +fn sender_person(options: &NotificationOptions, conversation: &Conversation) -> Retained { + let name = options + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + .unwrap_or(&conversation.name); + let avatar = conversation_image(conversation); + + // The handle ties messages of one conversation to one "person"; we key it + // by the conversation id, since that's the stable identity we have. + let handle = unsafe { + INPersonHandle::initWithValue_type( + INPersonHandle::alloc(), + Some(&NSString::from_str(&conversation.id)), + INPersonHandleType::Unknown, + ) + }; + unsafe { + INPerson::initWithPersonHandle_nameComponents_displayName_image_contactIdentifier_customIdentifier( + INPerson::alloc(), + &handle, + None, + Some(&NSString::from_str(name)), + avatar.as_deref(), + None, + Some(&NSString::from_str(&conversation.id)), + ) + } +} diff --git a/crates/notifications/src/sys/apple/delegate.rs b/crates/notifications/src/sys/apple/delegate.rs new file mode 100644 index 0000000..21f67a1 --- /dev/null +++ b/crates/notifications/src/sys/apple/delegate.rs @@ -0,0 +1,134 @@ +use block2::DynBlock; +use objc2::{define_class, msg_send, rc::Retained, AnyThread}; +use objc2_foundation::{NSObject, NSObjectProtocol}; +use objc2_user_notifications::{ + UNNotification, UNNotificationDefaultActionIdentifier, UNNotificationDismissActionIdentifier, + UNNotificationPresentationOptions, UNNotificationResponse, UNTextInputNotificationResponse, + UNUserNotificationCenter, UNUserNotificationCenterDelegate, +}; + +use crate::{Interaction, InteractionKind}; + +define_class!( + // Not MainThreadOnly: these callbacks can land on any thread. + #[unsafe(super(NSObject))] + pub(super) struct RobiusNotificationsDelegate; + + unsafe impl NSObjectProtocol for RobiusNotificationsDelegate {} + + unsafe impl UNUserNotificationCenterDelegate for RobiusNotificationsDelegate { + // The user did something with a notification: map it and hand it to the app. + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + #[allow(non_snake_case)] + unsafe fn userNotificationCenter_didReceiveNotificationResponse_withCompletionHandler( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + completion_handler: &DynBlock, + ) { + // A panicking app handler must not unwind across this ObjC frame, + // and the OS completion handler has to run no matter what. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + crate::deliver_interaction(interaction_from(response)); + })); + completion_handler.call(()); + } + + // Also show notifications while the app is in the foreground. + #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] + #[allow(non_snake_case)] + unsafe fn userNotificationCenter_willPresentNotification_withCompletionHandler( + &self, + _center: &UNUserNotificationCenter, + _notification: &UNNotification, + completion_handler: &DynBlock, + ) { + completion_handler.call((presentation_options(),)); + } + + // The user asked to see the app's own notification settings screen. + #[unsafe(method(userNotificationCenter:openSettingsForNotification:))] + #[allow(non_snake_case)] + unsafe fn userNotificationCenter_openSettingsForNotification( + &self, + _center: &UNUserNotificationCenter, + notification: Option<&UNNotification>, + ) { + // Same as above: a panicking app handler must not unwind + // across this ObjC frame. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + crate::deliver_interaction(open_settings_interaction(notification)); + })); + } + } +); + +impl RobiusNotificationsDelegate { + pub(super) fn new() -> Retained { + let this = Self::alloc().set_ivars(()); + unsafe { msg_send![super(this), init] } + } +} + +fn interaction_from(response: &UNNotificationResponse) -> Interaction { + let request = response.notification().request(); + let action_id = response.actionIdentifier(); + // These statics are just marker identifiers; reading them is harmless. + let (default_id, dismiss_id) = unsafe { + ( + UNNotificationDefaultActionIdentifier, + UNNotificationDismissActionIdentifier, + ) + }; + let kind = if *action_id == *default_id { + InteractionKind::Activated + } else if *action_id == *dismiss_id { + InteractionKind::Dismissed + } else if let Some(reply) = response.downcast_ref::() { + InteractionKind::Reply { + action_id: action_id.to_string(), + text: reply.userText().to_string(), + } + } else { + InteractionKind::Action { + id: action_id.to_string(), + } + }; + + Interaction { + notification_id: request.identifier().to_string(), + kind, + metadata: super::metadata_from_content(&request.content()), + } +} + +// The user may get to the settings link from one specific notification, or from none. +fn open_settings_interaction(notification: Option<&UNNotification>) -> Interaction { + let (notification_id, metadata) = match notification { + Some(notification) => { + let request = notification.request(); + ( + request.identifier().to_string(), + super::metadata_from_content(&request.content()), + ) + } + None => (String::new(), Vec::new()), + }; + Interaction { + notification_id, + kind: InteractionKind::OpenSettings, + metadata, + } +} + +fn presentation_options() -> UNNotificationPresentationOptions { + // Banner/List replaced Alert in iOS 14/macOS 11; fall back for anything older. + let show = if objc2::available!(ios = 14.0, macos = 11.0, ..) { + UNNotificationPresentationOptions::Banner | UNNotificationPresentationOptions::List + } else { + #[allow(deprecated)] + let alert = UNNotificationPresentationOptions::Alert; + alert + }; + show | UNNotificationPresentationOptions::Sound | UNNotificationPresentationOptions::Badge +} diff --git a/crates/notifications/src/sys/apple/mod.rs b/crates/notifications/src/sys/apple/mod.rs new file mode 100644 index 0000000..19f6df9 --- /dev/null +++ b/crates/notifications/src/sys/apple/mod.rs @@ -0,0 +1,659 @@ +#[cfg(feature = "apple-communication")] +mod communication; +mod delegate; + +use std::{ + path::{Path, PathBuf}, + ptr::NonNull, + sync::{ + atomic::{AtomicBool, AtomicU32, Ordering}, + mpsc, Mutex, OnceLock, + }, + time::{Duration, SystemTime}, +}; + +use block2::RcBlock; +use delegate::RobiusNotificationsDelegate as Delegate; +use objc2::{ + rc::Retained, + runtime::{Bool, ProtocolObject}, + sel, +}; +use objc2_foundation::{ + NSArray, NSBundle, NSDictionary, NSError, NSNumber, NSObjectProtocol, NSSet, NSString, NSURL, +}; +use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNErrorCode, UNMutableNotificationContent, + UNNotification, UNNotificationAction, UNNotificationActionOptions, UNNotificationAttachment, + UNNotificationCategory, UNNotificationCategoryOptions, UNNotificationContent, + UNNotificationInterruptionLevel, UNNotificationRequest, UNNotificationSetting, + UNNotificationSettings, UNNotificationSound, UNNotificationTrigger, + UNTextInputNotificationAction, UNTimeIntervalNotificationTrigger, UNUserNotificationCenter, + UNUserNotificationCenterDelegate, +}; + +use crate::{ + Action, ActionKind, ActiveIdsCallback, Error, NotificationOptions, NotificationSettings, + PermissionCallback, Result, SettingsCallback, SettingsScope, Sound, Urgency, +}; + +/// The userInfo key our metadata pairs are nested under. +const METADATA_KEY: &str = "robius.metadata"; + +/// The category used by notifications without actions, so their dismissals still reach us. +const BASE_CATEGORY_ID: &str = "robius-notifications-base"; + +/// The OS fires scheduled requests itself, even after the app exits. +pub(crate) const NATIVE_SCHEDULING: bool = true; + +pub(crate) fn show(options: NotificationOptions) -> Result<()> { + let center = notification_center()?; + // Without our delegate installed, a foregrounded app shows nothing. + ensure_delegate(¢er); + let content = build_content(¢er, &options)?; + // Conversation notifications get the full communication treatment + // (sender avatar, Focus integration) when the feature is enabled. + let content = enrich_content(content, &options); + // A scheduled time becomes an OS-side trigger; no trigger = deliver right + // away. Reusing an id replaces the older notification either way, and + // cancel() also removes still-pending scheduled requests. + let trigger = scheduled_trigger(&options); + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &NSString::from_str(&options.id), + &content, + trigger.as_deref(), + ); + // Fire and forget: success just means we handed it off to the OS. + center.addNotificationRequest_withCompletionHandler(&request, None); + Ok(()) +} + +/// Upgrades a conversation notification to a communication notification, +/// falling back to the plain content when that isn't possible (feature off, +/// no conversation, old OS, or missing entitlement). +#[cfg(feature = "apple-communication")] +fn enrich_content( + content: Retained, + options: &NotificationOptions, +) -> Retained { + communication::enrich(&content, options).unwrap_or_else(|| Retained::into_super(content)) +} + +#[cfg(not(feature = "apple-communication"))] +fn enrich_content( + content: Retained, + _options: &NotificationOptions, +) -> Retained { + Retained::into_super(content) +} + +// lib.rs guarantees the time is in the future when it's set at all. +fn scheduled_trigger(options: &NotificationOptions) -> Option> { + let time = options.scheduled_time?; + // The trigger interval must be > 0; clamp in case the deadline just passed. + let seconds = time + .duration_since(SystemTime::now()) + .unwrap_or(Duration::ZERO) + .as_secs_f64() + .max(0.001); + Some(Retained::into_super( + UNTimeIntervalNotificationTrigger::triggerWithTimeInterval_repeats(seconds, false), + )) +} + +// Nothing to update: these platforms don't render notification progress, +// so show() drew no bar in the first place. +pub(crate) fn update_progress(_options: &NotificationOptions) -> Result<()> { + Ok(()) +} + +pub(crate) fn cancel(id: &str) -> Result<()> { + let center = notification_center()?; + let ids = NSArray::from_retained_slice(&[NSString::from_str(id)]); + center.removePendingNotificationRequestsWithIdentifiers(&ids); + center.removeDeliveredNotificationsWithIdentifiers(&ids); + Ok(()) +} + +pub(crate) fn cancel_all() -> Result<()> { + let center = notification_center()?; + center.removeAllPendingNotificationRequests(); + center.removeAllDeliveredNotifications(); + Ok(()) +} + +pub(crate) fn request_permission(callback: PermissionCallback, provisional: bool) -> Result<()> { + let center = notification_center()?; + // Install the delegate now, so an OpenSettings event can reach us + // even if the app never shows a notification first. + ensure_delegate(¢er); + let mut options = + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound | UNAuthorizationOptions::Badge; + if crate::provides_notification_settings() { + // Makes the system's notification settings UI link back to the app. + options |= UNAuthorizationOptions::ProvidesAppNotificationSettings; + } + if provisional { + // Quiet delivery with no prompt; the OS grants this immediately. + options |= UNAuthorizationOptions::Provisional; + } + if crate::uses_critical_alerts() && !provisional { + // Needs the Apple-granted critical-alerts entitlement; without it, + // the OS just ignores this bit. Never OR'd into provisional requests: + // critical-alert authorization shows a prompt, and provisional + // promises not to. + options |= UNAuthorizationOptions::CriticalAlert; + } + // The block must be a `Fn`, but our callback is `FnOnce`: park it for the one call. + let callback = Mutex::new(Some(callback)); + let block = RcBlock::new(move |granted: Bool, error: *mut NSError| { + let Some(callback) = callback.lock().unwrap().take() else { + return; + }; + let result = permission_result(granted.as_bool(), error); + // This runs on a framework queue, so don't let a panicking callback + // unwind across the ObjC frame. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(result))); + }); + center.requestAuthorizationWithOptions_completionHandler(options, &block); + Ok(()) +} + +pub(crate) fn init_interaction_listener() -> Result<()> { + let center = notification_center()?; + ensure_delegate(¢er); + Ok(()) +} + +// Every scope reports app-level settings; that's all the OS exposes to apps here. +pub(crate) fn notification_settings(_scope: SettingsScope, callback: SettingsCallback) -> Result<()> { + let center = notification_center()?; + // The block must be a `Fn`, but our callback is `FnOnce`: park it for the one call. + let callback = Mutex::new(Some(callback)); + let block = RcBlock::new(move |settings: NonNull| { + let Some(callback) = callback.lock().unwrap().take() else { + return; + }; + let settings = map_settings(unsafe { settings.as_ref() }); + // This runs on a framework queue, so don't let a panicking callback + // unwind across the ObjC frame. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(Ok(settings)))); + }); + center.getNotificationSettingsWithCompletionHandler(&block); + Ok(()) +} + +pub(crate) fn set_app_badge(count: u32) -> Result<()> { + let center = notification_center()?; + // setBadgeCount: only exists on iOS 16+/macOS 13+; on older systems the + // badge just keeps following the last-delivered notification's count. + if !center.respondsToSelector(sel!(setBadgeCount:withCompletionHandler:)) { + return Ok(()); + } + center.setBadgeCount_withCompletionHandler(count as objc2_foundation::NSInteger, None); + Ok(()) +} + +pub(crate) fn active_notification_ids(callback: ActiveIdsCallback) -> Result<()> { + let center = notification_center()?; + // The block must be a `Fn`, but our callback is `FnOnce`: park it for the one call. + let callback = Mutex::new(Some(callback)); + let block = RcBlock::new(move |notifications: NonNull>| { + let Some(callback) = callback.lock().unwrap().take() else { + return; + }; + let ids: Vec = unsafe { notifications.as_ref() } + .to_vec() + .into_iter() + .map(|notification| notification.request().identifier().to_string()) + .collect(); + // This runs on a framework queue, so don't let a panicking callback + // unwind across the ObjC frame. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(Ok(ids)))); + }); + center.getDeliveredNotificationsWithCompletionHandler(&block); + Ok(()) +} + +// No per-channel or per-conversation pages here: every scope opens the app-level one. +pub(crate) fn open_notification_settings(_scope: SettingsScope) -> Result<()> { + ensure_app_bundle()?; + open_app_settings() +} + +// iOS: deep-link to our app's page in the Settings app. UIApplication is +// main-thread-only, so hop over there first. +#[cfg(target_os = "ios")] +fn open_app_settings() -> Result<()> { + dispatch2::run_on_main(|mtm| { + // "app-settings:" (iOS 8+). The notification-specific constant is + // iOS 15.4+ only and might not even link on older systems. + let url_string = unsafe { objc2_ui_kit::UIApplicationOpenSettingsURLString }; + let url = NSURL::URLWithString(url_string).ok_or(Error::Unknown)?; + let app = objc2_ui_kit::UIApplication::sharedApplication(mtm); + // An empty options dictionary, so nothing to get the types wrong on. + unsafe { app.openURL_options_completionHandler(&url, &NSDictionary::new(), None) }; + Ok(()) + }) +} + +// macOS: jump to the Notifications pane of System Settings. This URL scheme +// is undocumented, so it's best-effort; openURL tells us whether it worked. +#[cfg(target_os = "macos")] +fn open_app_settings() -> Result<()> { + let url = NSURL::URLWithString(&NSString::from_str( + "x-apple.systempreferences:com.apple.Notifications-Settings.extension", + )) + .ok_or(Error::Unsupported)?; + if objc2_app_kit::NSWorkspace::sharedWorkspace().openURL(&url) { + Ok(()) + } else { + Err(Error::Unsupported) + } +} + +// Other Apple platforms have no settings page we can open. +#[cfg(not(any(target_os = "ios", target_os = "macos")))] +fn open_app_settings() -> Result<()> { + Err(Error::Unsupported) +} + +fn map_settings(settings: &UNNotificationSettings) -> NotificationSettings { + // Provisional/Ephemeral are limited grants, but notifications do get shown. + let enabled = matches!( + settings.authorizationStatus(), + UNAuthorizationStatus::Authorized + | UNAuthorizationStatus::Provisional + | UNAuthorizationStatus::Ephemeral + ); + NotificationSettings { + enabled, + urgency: None, + sound_enabled: setting_flag(settings.soundSetting()), + badge_enabled: setting_flag(settings.badgeSetting()), + customized_by_user: None, + priority_conversation: None, + } +} + +// NotSupported (or anything unknown) means "can't say". +fn setting_flag(setting: UNNotificationSetting) -> Option { + match setting { + UNNotificationSetting::Enabled => Some(true), + UNNotificationSetting::Disabled => Some(false), + _ => None, + } +} + +// Installs our delegate on the center. show() needs this too (for foreground +// presentation); interactions with no app handler just queue up harmlessly. +fn ensure_delegate(center: &UNUserNotificationCenter) { + // The center only holds its delegate weakly, so this static keeps ours alive forever. + static DELEGATE: OnceLock = OnceLock::new(); + let delegate = DELEGATE.get_or_init(|| DelegateHolder(Delegate::new())); + let protocol: &ProtocolObject = + ProtocolObject::from_ref(&*delegate.0); + center.setDelegate(Some(protocol)); +} + +// After setup the delegate is only ever poked by ObjC callbacks, never from Rust. +struct DelegateHolder(Retained); +unsafe impl Send for DelegateHolder {} +unsafe impl Sync for DelegateHolder {} + +// Bail out when not running from a .app bundle: UNUserNotificationCenter throws +// an ObjC exception (killing the process) in unbundled binaries, e.g. `cargo run`. +// +// A bundle identifier alone isn't proof of one: a bare binary can carry an +// embedded `__info_plist` section (Makepad adds one), which gives it an +// identifier while the system still has no app registered for it. The bundle +// path is what actually tells the two apart. +fn ensure_app_bundle() -> Result<()> { + let bundle = NSBundle::mainBundle(); + let is_app_bundle = bundle.bundlePath().to_string().ends_with(".app"); + if is_app_bundle && bundle.bundleIdentifier().is_some() { + Ok(()) + } else { + Err(Error::NoAppBundle) + } +} + +/// The notification center, or [`Error::NoAppBundle`] if the OS refuses to +/// hand one out. +/// +/// Even a real `.app` can be unregistered with the system (e.g. a bundle +/// assembled by hand for development), and the first touch of the center +/// throws an ObjC exception that would abort the process. Catching it lets +/// the app carry on without notifications instead of dying. +fn notification_center() -> Result> { + ensure_app_bundle()?; + // The call has no side effects to unwind past; on failure the center + // simply never existed. + objc2::exception::catch(UNUserNotificationCenter::currentNotificationCenter) + .map_err(|_| Error::NoAppBundle) +} + +fn build_content( + center: &UNUserNotificationCenter, + options: &NotificationOptions, +) -> Result> { + let content = UNMutableNotificationContent::new(); + if let Some(title) = &options.title { + content.setTitle(&NSString::from_str(title)); + } + if let Some(subtitle) = &options.subtitle { + content.setSubtitle(&NSString::from_str(subtitle)); + } + if let Some(body) = &options.body { + content.setBody(&NSString::from_str(body)); + } + if let Some(count) = options.badge_count { + content.setBadge(Some(&NSNumber::new_u32(count))); + } + // An explicit group wins; otherwise a conversation groups its notifications. + let thread_id = options + .group + .as_deref() + .or_else(|| options.conversation.as_ref().map(|conversation| conversation.id.as_str())); + if let Some(thread_id) = thread_id { + content.setThreadIdentifier(&NSString::from_str(thread_id)); + } + if let Some(sound) = notification_sound(options.sound.as_ref(), options.bypass_dnd) { + content.setSound(Some(&sound)); + } + // bypass_dnd = a critical alert, which punches through DND/Focus. Without + // the critical-alerts entitlement the OS silently downgrades it — fine. + if options.bypass_dnd { + set_interruption_level(&content, UNNotificationInterruptionLevel::Critical); + } else if let Some(urgency) = options.urgency { + set_interruption_level(&content, interruption_level(urgency)); + } + if !options.metadata.is_empty() { + set_metadata(&content, &options.metadata); + } + if let Some(image) = &options.image { + let attachment = build_attachment(image)?; + content.setAttachments(&NSArray::from_retained_slice(&[attachment])); + } + // progress, timestamp, persistent, lock_screen_visibility, and + // conversation_messages have no UNNotification equivalent: no-ops here. + let category_id = ensure_categories_registered(center, &options.actions); + content.setCategoryIdentifier(&NSString::from_str(&category_id)); + Ok(content) +} + +fn notification_sound(sound: Option<&Sound>, critical: bool) -> Option> { + // Critical alerts need the critical sound variants to play during DND. + match (sound.unwrap_or(&Sound::Default), critical) { + // A nil sound means silence. + (Sound::Silent, _) => None, + (Sound::Default, false) => Some(UNNotificationSound::defaultSound()), + (Sound::Default, true) => Some(UNNotificationSound::defaultCriticalSound()), + (Sound::Named(name), false) => { + Some(UNNotificationSound::soundNamed(&NSString::from_str(name))) + } + (Sound::Named(name), true) => { + Some(UNNotificationSound::criticalSoundNamed(&NSString::from_str(name))) + } + } +} + +// interruptionLevel only exists on iOS 15+/macOS 12+; calling it on older systems would crash. +fn set_interruption_level( + content: &UNMutableNotificationContent, + level: UNNotificationInterruptionLevel, +) { + if !content.respondsToSelector(sel!(setInterruptionLevel:)) { + return; + } + content.setInterruptionLevel(level); +} + +fn interruption_level(urgency: Urgency) -> UNNotificationInterruptionLevel { + match urgency { + Urgency::Low => UNNotificationInterruptionLevel::Passive, + Urgency::Normal => UNNotificationInterruptionLevel::Active, + Urgency::Critical => UNNotificationInterruptionLevel::TimeSensitive, + } +} + +// Stash the metadata pairs in userInfo, nested under one key so we can find them again. +// A flat [k1, v1, k2, v2] array, not a dict: keeps order and duplicate keys, +// matching the other backends. +fn set_metadata(content: &UNMutableNotificationContent, metadata: &[(String, String)]) { + let flat: Vec> = metadata + .iter() + .flat_map(|(key, value)| [NSString::from_str(key), NSString::from_str(value)]) + .collect(); + let user_info = NSDictionary::from_retained_objects( + &[&*NSString::from_str(METADATA_KEY)], + &[NSArray::from_retained_slice(&flat)], + ); + // All plist-safe types (strings in an array in a dict), which is what userInfo wants. + unsafe { content.setUserInfo(&Retained::cast_unchecked(user_info)) }; +} + +/// Reads back the metadata pairs that [`set_metadata`] stored in userInfo. +pub(super) fn metadata_from_content(content: &UNNotificationContent) -> Vec<(String, String)> { + let Some(value) = content.userInfo().objectForKey(&NSString::from_str(METADATA_KEY)) else { + return Vec::new(); + }; + let Some(flat) = value.downcast_ref::() else { + return Vec::new(); + }; + let mut strings = flat + .iter() + .filter_map(|item| item.downcast::().ok()) + .map(|string| string.to_string()); + let mut pairs = Vec::new(); + while let (Some(key), Some(value)) = (strings.next(), strings.next()) { + pairs.push((key, value)); + } + pairs +} + +// UNNotificationCategory is immutable and only touched through ObjC. +struct RegisteredCategory { + id: String, + category: Retained, +} +unsafe impl Send for RegisteredCategory {} + +fn registered_categories() -> &'static Mutex> { + static CATEGORIES: Mutex> = Mutex::new(Vec::new()); + &CATEGORIES +} + +/// Makes sure a category matching `actions` (plus the base one) is registered, +/// and returns its id for the notification content. +fn ensure_categories_registered(center: &UNUserNotificationCenter, actions: &[Action]) -> String { + let category_id = if actions.is_empty() { + BASE_CATEGORY_ID.to_owned() + } else { + actions_category_id(actions) + }; + + let mut registered = registered_categories().lock().unwrap(); + seed_categories_from_system(center, &mut registered); + let mut added = register_category(&mut registered, BASE_CATEGORY_ID, &[]); + added |= register_category(&mut registered, &category_id, actions); + if added { + // setNotificationCategories replaces the whole set, so pass everything we've registered. + let all: Vec<_> = registered.iter().map(|entry| entry.category.clone()).collect(); + center.setNotificationCategories(&NSSet::from_retained_slice(&all)); + } + category_id +} + +// The daemon's category set outlives us, but ours starts empty each run and +// setNotificationCategories replaces the whole set. So before our first replace, +// pull in what's already registered; otherwise notifications still up from a +// previous run would lose their action buttons. +fn seed_categories_from_system( + center: &UNUserNotificationCenter, + registered: &mut Vec, +) { + static SEEDED: AtomicBool = AtomicBool::new(false); + // One attempt per run; callers hold the registry lock, so no one races us. + if SEEDED.swap(true, Ordering::Relaxed) { + return; + } + let (tx, rx) = mpsc::channel(); + let block = RcBlock::new(move |set: NonNull>| { + let existing: Vec = unsafe { set.as_ref() } + .to_vec() + .into_iter() + .map(|category| RegisteredCategory { + id: category.identifier().to_string(), + category, + }) + .collect(); + let _ = tx.send(existing); + }); + center.getNotificationCategoriesWithCompletionHandler(&block); + // The completion runs on a framework queue, so blocking here (even on the + // main thread) is safe. On timeout just proceed unseeded. + let Ok(existing) = rx.recv_timeout(Duration::from_secs(2)) else { + return; + }; + for entry in existing { + if !registered.iter().any(|known| known.id == entry.id) { + registered.push(entry); + } + } +} + +fn register_category(registered: &mut Vec, id: &str, actions: &[Action]) -> bool { + if registered.iter().any(|entry| entry.id == id) { + return false; + } + registered.push(RegisteredCategory { + id: id.to_owned(), + category: build_category(id, actions), + }); + true +} + +// Same action set -> same id, so categories don't pile up across shows. +// FNV-1a by hand (like windows' tag()): the id must be identical across runs +// and Rust versions, which DefaultHasher doesn't guarantee. +fn actions_category_id(actions: &[Action]) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for action in actions { + let fields = [ + action.id.as_str(), + action.title.as_str(), + if matches!(action.kind, ActionKind::Reply) { "reply" } else { "button" }, + action.placeholder.as_deref().unwrap_or(""), + if action.destructive { "1" } else { "0" }, + if action.foreground { "1" } else { "0" }, + ]; + for field in fields { + // A 0 byte ends each field, so shifted boundaries can't collide. + for byte in field.bytes().chain(std::iter::once(0)) { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + } + } + format!("robius-notifications-actions-{hash:016x}") +} + +fn build_category(id: &str, actions: &[Action]) -> Retained { + let actions: Vec> = actions.iter().map(build_action).collect(); + // CustomDismissAction makes the system tell our delegate about dismissals. + UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options( + &NSString::from_str(id), + &NSArray::from_retained_slice(&actions), + &NSArray::new(), + UNNotificationCategoryOptions::CustomDismissAction, + ) +} + +fn build_action(action: &Action) -> Retained { + let mut options = UNNotificationActionOptions::empty(); + if action.destructive { + options |= UNNotificationActionOptions::Destructive; + } + if action.foreground { + options |= UNNotificationActionOptions::Foreground; + } + let id = NSString::from_str(&action.id); + let title = NSString::from_str(&action.title); + match action.kind { + ActionKind::Button => { + UNNotificationAction::actionWithIdentifier_title_options(&id, &title, options) + } + ActionKind::Reply => { + let placeholder = NSString::from_str(action.placeholder.as_deref().unwrap_or("")); + Retained::into_super( + UNTextInputNotificationAction::actionWithIdentifier_title_options_textInputButtonTitle_textInputPlaceholder( + &id, &title, options, &title, &placeholder, + ), + ) + } + } +} + +// The system MOVES the attached file into its own store (only bundle-internal +// files get copied), so hand it a throwaway copy and keep the caller's file intact. +fn build_attachment(path: &Path) -> Result> { + let copy = copy_for_attachment(path)?; + let copy_path = copy.to_str().ok_or(Error::InvalidNotification)?; + let url = NSURL::fileURLWithPath(&NSString::from_str(copy_path)); + // An empty identifier makes the system generate one. + unsafe { + UNNotificationAttachment::attachmentWithIdentifier_URL_options_error( + &NSString::from_str(""), + &url, + None, + ) + } + // e.g. an unsupported file type or an over-sized image + .map_err(|_| { + let _ = std::fs::remove_file(©); + Error::InvalidNotification + }) +} + +// A unique temp path per attachment. The extension survives the copy because +// the system uses it to infer the file type. +fn copy_for_attachment(path: &Path) -> Result { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut name = format!("robius-notification-{}-{count}", std::process::id()); + if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) { + name.push('.'); + name.push_str(ext); + } + let copy = std::env::temp_dir().join(name); + std::fs::copy(path, ©)?; + Ok(copy) +} + +fn permission_result(granted: bool, error: *mut NSError) -> Result { + if granted { + return Ok(true); + } + if error.is_null() { + return Ok(false); + } + // "Not allowed" is just a denial; anything else we can't say much about. + match UNErrorCode(unsafe { &*error }.code()) { + UNErrorCode::NotificationsNotAllowed => Ok(false), + _ => Err(Error::Unknown), + } +} + +#[cfg(test)] +mod tests { + // A `cargo test` binary isn't a .app bundle, so the guard must say no — touching + // UNUserNotificationCenter here would throw an ObjC exception and kill the process. + #[test] + fn unpackaged_binary_has_no_app_bundle() { + assert!(matches!( + super::ensure_app_bundle(), + Err(crate::Error::NoAppBundle) + )); + } +} diff --git a/crates/notifications/src/sys/linux.rs b/crates/notifications/src/sys/linux.rs new file mode 100644 index 0000000..d80bedf --- /dev/null +++ b/crates/notifications/src/sys/linux.rs @@ -0,0 +1,655 @@ +//! Linux backend: the `org.freedesktop.Notifications` D-Bus service, +//! which works on both X11 and Wayland. + +use std::{ + collections::HashMap, + panic::AssertUnwindSafe, + sync::{Mutex, OnceLock}, + time::Duration, +}; + +use zbus::{ + blocking::{Connection, Proxy}, + zvariant::Value, + Message, +}; + +use crate::{ + ActionKind, ActiveIdsCallback, Error, Interaction, InteractionKind, NotificationOptions, + NotificationSettings, PermissionCallback, Progress, Result, SettingsCallback, SettingsScope, + Sound, Urgency, +}; + +/// No OS-side scheduling; lib.rs's in-process timer fires scheduled showings, +/// so `show()` never sees a future `scheduled_time`. +pub(crate) const NATIVE_SCHEDULING: bool = false; + +/// Work handed to the dedicated D-Bus thread; see [`run_blocking`]. +type Job = Box; + +thread_local! { + /// Whether this thread is our D-Bus worker, so nested calls run inline. + static ON_WORKER: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Runs blocking D-Bus work on a dedicated thread, then hands back the result. +/// +/// zbus's blocking API drives an executor internally, and with the `tokio` +/// feature that means `Runtime::block_on`, which panics outright when the +/// calling thread is already inside a tokio runtime -- a perfectly normal +/// place for an app to call us from. Hopping onto our own thread keeps every +/// entry point safe to call from anywhere, whichever executor is selected. +fn run_blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + // A nested call (one of our own functions calling another) is already + // off-runtime; running it inline avoids waiting on ourselves forever. + if ON_WORKER.with(|on| on.get()) { + return work(); + } + + static JOBS: OnceLock>> = OnceLock::new(); + let sender = JOBS + .get_or_init(|| { + let (sender, receiver) = std::sync::mpsc::channel::(); + // Detached on purpose: it lives as long as the process does. + std::thread::Builder::new() + .name("robius-notifications-dbus".to_owned()) + .spawn(move || { + ON_WORKER.with(|on| on.set(true)); + for job in receiver { + // A panicking job mustn't take the worker down with it. + let _ = std::panic::catch_unwind(AssertUnwindSafe(job)); + } + }) + .expect("failed to spawn the D-Bus worker thread"); + Mutex::new(sender) + }) + .lock() + .unwrap() + .clone(); + + let (result_sender, result_receiver) = std::sync::mpsc::channel(); + let job: Job = Box::new(move || { + let _ = result_sender.send(work()); + }); + sender.send(job).map_err(|_| Error::Unknown)?; + // The result sender is only dropped unsent if the job itself panicked. + result_receiver.recv().map_err(|_| Error::Unknown)? +} + +const DEST: &str = "org.freedesktop.Notifications"; +const PATH: &str = "/org/freedesktop/Notifications"; +const IFACE: &str = "org.freedesktop.Notifications"; + +/// Wire prefix for user action ids, so they can't collide with the +/// reserved "default" and "inline-reply" keys. +const ACTION_ID_PREFIX: &str = "app."; + +/// What we remember about a shown notification, so that later signals +/// about it (by server id) can be turned back into `Interaction`s. +#[derive(Clone)] +struct Shown { + /// Our notification id, from `NotificationOptions.id`. + id: String, + metadata: Vec<(String, String)>, + /// The id of the action shown as an inline reply field, if any. + reply_action_id: Option, +} + +/// Our notification id -> the server's u32 id, for replacing and canceling. +fn server_ids() -> &'static Mutex> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(Mutex::default) +} + +/// The server's u32 id -> our info about that notification. +fn shown_notifications() -> &'static Mutex> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(Mutex::default) +} + +/// Opens a session bus connection with a method call timeout, +/// so a hung daemon can't block callers forever. +fn open_session() -> Result { + zbus::blocking::connection::Builder::session() + .and_then(|builder| builder.method_timeout(Duration::from_secs(25)).build()) + // no usable session bus means notifications can't work here + .map_err(|_| Error::NoService) +} + +/// The shared session bus connection used for method calls. +fn connection() -> Result { + static CONNECTION: OnceLock = OnceLock::new(); + if let Some(connection) = CONNECTION.get() { + return Ok(connection.clone()); + } + let connection = open_session()?; + Ok(CONNECTION.get_or_init(|| connection).clone()) +} + +fn notifications_proxy(connection: &Connection) -> Result> { + Proxy::new(connection, DEST, PATH, IFACE).map_err(map_zbus_error) +} + +/// The daemon capabilities we care about. +#[derive(Clone, Copy)] +struct Capabilities { + actions: bool, + body_markup: bool, + inline_reply: bool, +} + +/// Asks the daemon what it supports, cached after the first successful ask. +fn capabilities(proxy: &Proxy<'_>) -> Capabilities { + static CAPABILITIES: Mutex> = Mutex::new(None); + if let Some(capabilities) = *CAPABILITIES.lock().unwrap() { + return capabilities; + } + match proxy.call::<_, _, Vec>("GetCapabilities", &()) { + Ok(list) => { + let capabilities = Capabilities { + actions: list.iter().any(|cap| cap == "actions"), + body_markup: list.iter().any(|cap| cap == "body-markup"), + inline_reply: list.iter().any(|cap| cap == "inline-reply"), + }; + *CAPABILITIES.lock().unwrap() = Some(capabilities); + capabilities + } + // couldn't ask; assume the least, but don't cache that + Err(_) => Capabilities { + actions: false, + body_markup: false, + inline_reply: false, + }, + } +} + +/// Maps a zbus error, turning "no notification daemon on the bus" into NoService. +fn map_zbus_error(err: zbus::Error) -> Error { + let no_service = match &err { + zbus::Error::MethodError(name, _, _) => matches!( + name.as_str(), + "org.freedesktop.DBus.Error.ServiceUnknown" + | "org.freedesktop.DBus.Error.NameHasNoOwner" + ), + zbus::Error::FDO(fdo_err) => matches!( + &**fdo_err, + zbus::fdo::Error::ServiceUnknown(_) | zbus::fdo::Error::NameHasNoOwner(_) + ), + _ => false, + }; + if no_service { + Error::NoService + } else { + Error::DBus(err) + } +} + +fn show_inner(options: NotificationOptions) -> Result<()> { + // Make sure the signal listener is up so interactions get delivered. + let _ = ensure_listener(); + + let connection = connection()?; + let proxy = notifications_proxy(&connection)?; + let capabilities = capabilities(&proxy); + + // The summary is the title, or the body when there's no title. + let mut body_lines = Vec::new(); + // only escape body text when the daemon actually parses markup + let escape = |text: &str| { + if capabilities.body_markup { + escape_markup(text) + } else { + text.to_owned() + } + }; + let summary = match options.title.as_deref().filter(|t| !t.trim().is_empty()) { + Some(title) => { + if let Some(body) = &options.body { + body_lines.push(escape(body)); + } + title.to_owned() + } + None => options.body.clone().unwrap_or_default(), + }; + // Linux has no dedicated subtitle, so it goes on its own line in the body. + if let Some(subtitle) = &options.subtitle { + body_lines.push(escape(subtitle)); + } + let body = body_lines.join("\n"); + + // Actions are flat [key, label, ...] pairs; the "default" one is + // what a click on the notification body itself invokes. + let mut actions = Vec::new(); + let mut reply_action_id = None; + let mut reply_placeholder = None; + if capabilities.actions { + actions.push("default".to_owned()); + actions.push("Open".to_owned()); + for action in &options.actions { + let inline_reply = action.kind == ActionKind::Reply + && capabilities.inline_reply + && reply_action_id.is_none(); + if inline_reply { + // KDE-style inline reply; the text comes back via NotificationReplied + reply_action_id = Some(action.id.clone()); + reply_placeholder = action.placeholder.clone(); + actions.push("inline-reply".to_owned()); + } else { + // reply actions degrade to plain buttons without inline-reply support + actions.push(format!("{ACTION_ID_PREFIX}{}", action.id)); + } + actions.push(action.title.clone()); + } + } + + let mut hints: HashMap<&str, Value> = HashMap::new(); + let urgency = match options.urgency.unwrap_or_default() { + Urgency::Low => 0u8, + Urgency::Normal => 1, + Urgency::Critical => 2, + }; + hints.insert("urgency", Value::U8(urgency)); + if let Some(desktop_entry) = crate::app_id() { + // lets the daemon find the app's name and icon from its .desktop file + hints.insert("desktop-entry", Value::from(desktop_entry)); + } + if options.conversation.is_some() { + // advisory: tells the daemon this is a received chat message + hints.insert("category", Value::from("im.received")); + } + if let Some(image) = &options.image { + let image = std::fs::canonicalize(image)?; + hints.insert("image-path", Value::from(image.to_string_lossy().into_owned())); + } + match &options.sound { + Some(Sound::Silent) => { + hints.insert("suppress-sound", Value::from(true)); + } + Some(Sound::Named(name)) => { + hints.insert("sound-name", Value::from(name.clone())); + } + _ => {} + } + if let Some(placeholder) = reply_placeholder { + hints.insert("x-kde-reply-placeholder-text", Value::from(placeholder)); + } + if let Some(Progress::Determinate { current, total }) = options.progress { + // "value" is the daemon's percentage hint; total > 0 is pre-validated. + // Indeterminate gets no hint: there's no daemon convention for it. + let percent = (u64::from(current) * 100 / u64::from(total)).min(100) as i32; + hints.insert("value", Value::I32(percent)); + } + if options.persistent { + hints.insert("resident", Value::from(true)); + } + // no daemon equivalents: timestamp, lock_screen_visibility, bypass_dnd, + // and conversation_messages (daemons render one body; history is Android-only) + + // 0 means "never expires" to the daemon, so round tiny timeouts up to 1ms; + // persistent notifications always get 0, regardless of any timeout. + let expire_timeout = if options.persistent { + 0 + } else { + options + .timeout + .map_or(-1, |timeout| timeout.as_millis().clamp(1, i32::MAX as u128) as i32) + }; + // Reusing the previous server id makes the daemon replace that notification. + let replaces_id = server_ids() + .lock() + .unwrap() + .get(&options.id) + .copied() + .unwrap_or(0); + + let server_id: u32 = proxy + .call( + "Notify", + &( + app_name(), + replaces_id, + "", // app_icon: the desktop-entry hint covers this + summary, + body, + actions, + hints, + expire_timeout, + ), + ) + .map_err(map_zbus_error)?; + + server_ids().lock().unwrap().insert(options.id.clone(), server_id); + let mut shown = shown_notifications().lock().unwrap(); + if replaces_id != 0 && replaces_id != server_id { + // the daemon gave the replacement a fresh id; drop the stale entry + shown.remove(&replaces_id); + } + shown.insert( + server_id, + Shown { + id: options.id, + metadata: options.metadata, + reply_action_id, + }, + ); + Ok(()) +} + +pub(crate) fn update_progress(options: &NotificationOptions) -> Result<()> { + // Re-running Notify reuses the previous server id (replaces_id), which + // updates the notification in place. Force the update itself silent + // (suppress-sound hint), since some daemons re-alert on replacement. + let mut options = options.clone(); + options.sound = Some(Sound::Silent); + show(options) +} + +fn cancel_inner(id: &str) -> Result<()> { + let Some(server_id) = server_ids().lock().unwrap().get(id).copied() else { + // never shown (or already closed): nothing to cancel + return Ok(()); + }; + let connection = connection()?; + let proxy = notifications_proxy(&connection)?; + close_notification(&proxy, server_id) +} + +fn cancel_all_inner() -> Result<()> { + let ids: Vec = server_ids().lock().unwrap().values().copied().collect(); + if ids.is_empty() { + return Ok(()); + } + let connection = connection()?; + let proxy = notifications_proxy(&connection)?; + for server_id in ids { + close_notification(&proxy, server_id)?; + } + Ok(()) +} + +pub(crate) fn show(options: NotificationOptions) -> Result<()> { + run_blocking(move || show_inner(options)) +} + +pub(crate) fn cancel(id: &str) -> Result<()> { + let id = id.to_owned(); + run_blocking(move || cancel_inner(&id)) +} + +pub(crate) fn cancel_all() -> Result<()> { + run_blocking(cancel_all_inner) +} + +fn close_notification(proxy: &Proxy<'_>, server_id: u32) -> Result<()> { + match proxy + .call::<_, _, ()>("CloseNotification", &server_id) + .map_err(map_zbus_error) + { + // some daemons error when the notification is already gone; fine by us + Err(Error::DBus(zbus::Error::MethodError(..))) => Ok(()), + result => result, + } +} + +pub(crate) fn request_permission(callback: PermissionCallback, _provisional: bool) -> Result<()> { + // Linux has no notification permission prompt, provisional or otherwise. + callback(Ok(true)); + Ok(()) +} + +fn set_app_badge_inner(count: u32) -> Result<()> { + // There's no freedesktop standard for badges, but the de-facto Unity + // LauncherEntry signal is still honored by several desktops and docks + // (KDE Plasma, elementary, Dash-to-Dock). It attributes by .desktop id, + // so without a `set_app_id` there's nothing to hang the badge on. + let Some(desktop_entry) = crate::app_id() else { + return Ok(()); + }; + let connection = connection()?; + let mut properties: HashMap<&str, Value> = HashMap::new(); + properties.insert("count", Value::I64(i64::from(count))); + properties.insert("count-visible", Value::from(count > 0)); + connection + .emit_signal( + Option::::None, + "/com/canonical/unity/launcherentry/robius", + "com.canonical.Unity.LauncherEntry", + "Update", + &(format!("application://{desktop_entry}.desktop"), properties), + ) + .map_err(map_zbus_error) +} + +pub(crate) fn active_notification_ids(callback: ActiveIdsCallback) -> Result<()> { + // Entries are pruned on NotificationClosed, so what's still tracked + // approximates "still showing". Dedupe in case a replacement briefly + // left two server ids pointing at the same crate id. + let mut ids: Vec = shown_notifications() + .lock() + .unwrap() + .values() + .map(|shown| shown.id.clone()) + .collect(); + ids.sort_unstable(); + ids.dedup(); + callback(Ok(ids)); + Ok(()) +} + +fn notification_settings_inner(callback: SettingsCallback) -> Result<()> { + // Linux has no per-app/channel/conversation settings; "enabled" just + // means the session bus and a notification daemon are reachable. + let enabled = service_reachable()?; + callback(Ok(NotificationSettings { + enabled, + urgency: None, + sound_enabled: None, + badge_enabled: None, + customized_by_user: None, + priority_conversation: None, + })); + Ok(()) +} + +pub(crate) fn set_app_badge(count: u32) -> Result<()> { + run_blocking(move || set_app_badge_inner(count)) +} + +pub(crate) fn notification_settings( + _scope: SettingsScope, + callback: SettingsCallback, +) -> Result<()> { + run_blocking(move || notification_settings_inner(callback)) +} + +/// Whether the session bus and a notification daemon are reachable. +fn service_reachable() -> Result { + let proxy = match connection().and_then(|connection| notifications_proxy(&connection)) { + Ok(proxy) => proxy, + Err(Error::NoService) => return Ok(false), + Err(err) => return Err(err), + }; + // building the proxy doesn't touch the bus, so actually ask the daemon + match proxy + .call::<_, _, (String, String, String, String)>("GetServerInformation", &()) + .map_err(map_zbus_error) + { + Ok(_) => Ok(true), + Err(Error::NoService) => Ok(false), + Err(err) => Err(err), + } +} + +pub(crate) fn open_notification_settings(_scope: SettingsScope) -> Result<()> { + // no standard cross-desktop way to open notification settings + Err(Error::Unsupported) +} + +pub(crate) fn init_interaction_listener() -> Result<()> { + run_blocking(ensure_listener) +} + +/// Starts the signal listener thread (once). It owns its own connection +/// so its blocking reads don't get in the way of method calls. +fn ensure_listener() -> Result<()> { + static STARTED: Mutex = Mutex::new(false); + let mut started = STARTED.lock().unwrap(); + if *started { + return Ok(()); + } + + let connection = open_session()?; + let proxy = notifications_proxy(&connection)?; + // One iterator covers all of the daemon's signals; we dispatch by name. + let signals = proxy.receive_all_signals().map_err(map_zbus_error)?; + + // A restarted daemon forgets our notifications and hands out low ids + // again, so forget ours too whenever the name changes owner. + let dbus_proxy = zbus::blocking::fdo::DBusProxy::new(&connection).map_err(map_zbus_error)?; + let owner_changes = dbus_proxy + .receive_name_owner_changed_with_args(&[(0, DEST)]) + .map_err(map_zbus_error)?; + std::thread::Builder::new() + .name("robius-notifications-watch".to_owned()) + .spawn(move || { + for change in owner_changes { + // A daemon first acquiring the name (e.g. activated by our own + // first Notify) doesn't invalidate anything; a lost or replaced + // owner does. + let restarted = change + .args() + .map(|args| args.old_owner().is_some()) + .unwrap_or(true); + if restarted { + server_ids().lock().unwrap().clear(); + shown_notifications().lock().unwrap().clear(); + } + } + }) + .map_err(Error::Io)?; + + std::thread::Builder::new() + .name("robius-notifications".to_owned()) + .spawn(move || { + // keep the connection alive for as long as we're listening + let _connection = connection; + for message in signals { + // a panicking interaction handler shouldn't kill this thread + let _ = std::panic::catch_unwind(AssertUnwindSafe(|| handle_signal(&message))); + } + // the connection died; let a later show()/init spawn a new listener + *STARTED.lock().unwrap() = false; + }) + .map_err(Error::Io)?; + *started = true; + Ok(()) +} + +/// Turns a daemon signal back into an app-facing interaction. +fn handle_signal(message: &Message) { + let header = message.header(); + let Some(member) = header.member() else { + return; + }; + match member.as_str() { + "ActionInvoked" => { + let Ok((server_id, action)) = message.body().deserialize::<(u32, String)>() else { + return; + }; + let Some(shown) = lookup(server_id) else { + return; + }; + let kind = match action.as_str() { + // "default" is our body-click action + "default" => InteractionKind::Activated, + // the daemon's inline-reply button maps back to our reply action + "inline-reply" => InteractionKind::Action { + id: shown.reply_action_id.clone().unwrap_or(action), + }, + // user action ids were prefixed on the wire; undo that here + _ => InteractionKind::Action { + id: match action.strip_prefix(ACTION_ID_PREFIX) { + Some(id) => id.to_owned(), + None => action, + }, + }, + }; + deliver(shown, kind); + } + // KDE's inline reply: the daemon sends us the submitted text directly + "NotificationReplied" => { + let Ok((server_id, text)) = message.body().deserialize::<(u32, String)>() else { + return; + }; + let Some(shown) = lookup(server_id) else { + return; + }; + let Some(action_id) = shown.reply_action_id.clone() else { + return; + }; + deliver(shown, InteractionKind::Reply { action_id, text }); + } + "NotificationClosed" => { + let Ok((server_id, reason)) = message.body().deserialize::<(u32, u32)>() else { + return; + }; + let Some(shown) = remove(server_id) else { + return; + }; + // reason 2 = dismissed by the user; other reasons aren't interactions + if reason == 2 { + deliver(shown, InteractionKind::Dismissed); + } + } + _ => {} + } +} + +fn deliver(shown: Shown, kind: InteractionKind) { + crate::deliver_interaction(Interaction { + notification_id: shown.id, + kind, + metadata: shown.metadata, + }); +} + +fn lookup(server_id: u32) -> Option { + shown_notifications().lock().unwrap().get(&server_id).cloned() +} + +/// Forgets a closed notification, in both maps. +fn remove(server_id: u32) -> Option { + let shown = shown_notifications().lock().unwrap().remove(&server_id)?; + let mut ids = server_ids().lock().unwrap(); + // a replacement may have re-pointed our id at a newer server id; keep that + if ids.get(&shown.id) == Some(&server_id) { + ids.remove(&shown.id); + } + Some(shown) +} + +/// The app name shown by the daemon: the app id if set, else the exe name. +fn app_name() -> String { + crate::app_id().or_else(exe_name).unwrap_or_default() +} + +fn exe_name() -> Option { + let exe = std::env::current_exe().ok()?; + Some(exe.file_stem()?.to_string_lossy().into_owned()) +} + +/// Markup-capable daemons render the body as limited markup, so escape user text. +fn escape_markup(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(c), + } + } + out +} diff --git a/crates/notifications/src/sys/unsupported.rs b/crates/notifications/src/sys/unsupported.rs new file mode 100644 index 0000000..49dbd1e --- /dev/null +++ b/crates/notifications/src/sys/unsupported.rs @@ -0,0 +1,47 @@ +use crate::{ + ActiveIdsCallback, Error, NotificationOptions, PermissionCallback, Result, SettingsCallback, + SettingsScope, +}; + +/// No OS-side scheduling; `show()` uses the crate's in-process fallback timer. +pub(crate) const NATIVE_SCHEDULING: bool = false; + +pub(crate) fn show(_: NotificationOptions) -> Result<()> { + Err(Error::Unsupported) +} + +pub(crate) fn update_progress(_: &NotificationOptions) -> Result<()> { + Err(Error::Unsupported) +} + +pub(crate) fn cancel(_: &str) -> Result<()> { + Err(Error::Unsupported) +} + +pub(crate) fn cancel_all() -> Result<()> { + Err(Error::Unsupported) +} + +pub(crate) fn request_permission(_: PermissionCallback, _provisional: bool) -> Result<()> { + Err(Error::Unsupported) +} + +pub(crate) fn init_interaction_listener() -> Result<()> { + Err(Error::Unsupported) +} + +pub(crate) fn notification_settings(_: SettingsScope, _: SettingsCallback) -> Result<()> { + Err(Error::Unsupported) +} + +pub(crate) fn open_notification_settings(_: SettingsScope) -> Result<()> { + Err(Error::Unsupported) +} + +pub(crate) fn active_notification_ids(_: ActiveIdsCallback) -> Result<()> { + Err(Error::Unsupported) +} + +pub(crate) fn set_app_badge(_: u32) -> Result<()> { + Err(Error::Unsupported) +} diff --git a/crates/notifications/src/sys/windows.rs b/crates/notifications/src/sys/windows.rs new file mode 100644 index 0000000..4e2c610 --- /dev/null +++ b/crates/notifications/src/sys/windows.rs @@ -0,0 +1,725 @@ +//! Windows backend: WinRT toast notifications. +//! +//! Interactions are delivered via per-toast event handlers, so they only +//! arrive while the app is running; activating a toast after the app exits +//! won't relaunch it (that would need a registered COM activator). +//! Scheduled toasts fire even after the app exits, but they can't carry +//! those per-toast handlers at all, so interactions with them are lost. + +use std::{ + collections::HashMap, + fmt::Write as _, + path::Path, + sync::{Mutex, OnceLock}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use windows::{ + core::{IInspectable, Interface, HSTRING}, + Data::Xml::Dom::XmlDocument, + Foundation::{DateTime, IPropertyValue, IReference, PropertyValue, TypedEventHandler, Uri}, + System::Launcher, + UI::Notifications::{ + BadgeNotification, BadgeUpdateManager, NotificationData, NotificationSetting, NotificationUpdateResult, + ScheduledToastNotification, ToastActivatedEventArgs, ToastDismissalReason, + ToastDismissedEventArgs, ToastNotification, ToastNotificationManager, ToastNotifier, + }, + Win32::{System::Com::CoTaskMemFree, UI::Shell::GetCurrentProcessExplicitAppUserModelID}, +}; + +use crate::{ + ActionKind, ActiveIdsCallback, Error, Interaction, InteractionKind, NotificationOptions, + NotificationSettings, PermissionCallback, Progress, Result, SettingsCallback, SettingsScope, + Sound, Urgency, +}; + +/// The OS schedules toasts for us (they even fire after the app exits). +pub(crate) const NATIVE_SCHEDULING: bool = true; + +/// All our toasts share one group; the tag alone identifies each notification. +const GROUP: &str = "robius"; + +/// The built-in PowerShell AUMID, borrowed so unpackaged dev builds can still +/// show toasts (they get attributed to "Windows PowerShell"). +const POWERSHELL_AUMID: &str = + "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"; + +pub(crate) fn show(options: NotificationOptions) -> Result<()> { + let notifier = ToastNotificationManager::CreateToastNotifierWithId(&aumid())?; + if notifier.Setting()? != NotificationSetting::Enabled { + return Err(Error::PermissionDenied); + } + + // Lets active_notification_ids translate the OS tag back to our id. + remember_tag(&options.id); + + if options.scheduled_time.is_some() { + return add_to_schedule(¬ifier, &options); + } + + let toast_tag = tag(&options.id); + // An immediate show supersedes a still-pending scheduled toast of this id, + // just like scheduling one replaces its predecessor. + let _ = remove_scheduled(¬ifier, Some(&toast_tag)); + let quiet = if options.progress.is_some() { + // A progress toast alerts only when first shown; re-shows stay quiet. + progress_seqs().lock().unwrap().contains_key(&toast_tag) + } else { + // Re-shown without progress: no longer a progress notification. + progress_seqs().lock().unwrap().remove(&toast_tag); + false + }; + show_toast(¬ifier, &options, quiet) +} + +/// Shows the toast right now. `quiet` (progress re-shows and updates) +/// suppresses the popup and sound so the user isn't re-alerted. +fn show_toast(notifier: &ToastNotifier, options: &NotificationOptions, quiet: bool) -> Result<()> { + let document = XmlDocument::new()?; + document.LoadXml(&HSTRING::from(build_toast_xml(options, quiet)?))?; + + let toast = ToastNotification::CreateToastNotification(&document)?; + let toast_tag = tag(&options.id); + // Same tag + group as an earlier toast makes the OS replace it. + toast.SetTag(&HSTRING::from(toast_tag.as_str()))?; + toast.SetGroup(&HSTRING::from(GROUP))?; + + if let Some(progress) = options.progress { + // The XML's {progressValue}/{progressStatus} bindings read from this. + let sequence = bump_progress_seq(&toast_tag); + toast.SetData(&progress_data(progress, sequence)?)?; + } + + if let Some(timeout) = options.timeout { + set_expiration(&toast, timeout)?; + } + if quiet || matches!(options.urgency, Some(Urgency::Low)) { + // No popup banner; the toast lands quietly in the action center. + toast.SetSuppressPopup(true)?; + } + + register_interaction_handlers(&toast, options)?; + notifier.Show(&toast)?; + Ok(()) +} + +/// Hands the toast to the OS to show at its scheduled time. Scheduled toasts +/// can't carry our per-toast event handlers, so interactions with them are +/// lost (see the module docs). +fn add_to_schedule(notifier: &ToastNotifier, options: &NotificationOptions) -> Result<()> { + // Guaranteed Some and in the future by lib.rs. + let time = options.scheduled_time.unwrap_or_else(SystemTime::now); + let toast_tag = tag(&options.id); + // Same id replaces the earlier pending toast, like Show does for shown ones. + remove_scheduled(notifier, Some(&toast_tag))?; + + let document = XmlDocument::new()?; + document.LoadXml(&HSTRING::from(build_toast_xml(options, false)?))?; + + let toast = + ScheduledToastNotification::CreateScheduledToastNotification(&document, datetime(time))?; + // Same tag/group scheme as regular toasts, so cancel() can find it. + toast.SetTag(&HSTRING::from(toast_tag.as_str()))?; + toast.SetGroup(&HSTRING::from(GROUP))?; + if let Some(timeout) = options.timeout { + let expiration = PropertyValue::CreateDateTime(datetime(time + timeout))?; + // Best effort: older Windows versions don't support this on scheduled toasts. + let _ = toast.SetExpirationTime(&expiration.cast::>()?); + } + if matches!(options.urgency, Some(Urgency::Low)) { + toast.SetSuppressPopup(true)?; + } + + notifier.AddToSchedule(&toast)?; + Ok(()) +} + +pub(crate) fn update_progress(options: &NotificationOptions) -> Result<()> { + // lib.rs only calls this with progress set; be safe anyway. + let Some(progress) = options.progress else { + return Err(Error::InvalidNotification); + }; + let notifier = ToastNotificationManager::CreateToastNotifierWithId(&aumid())?; + let toast_tag = tag(&options.id); + // No sequence entry means cancel() got here first (or it was never + // shown): drop the update rather than resurrect a cancelled toast. + if !progress_seqs().lock().unwrap().contains_key(&toast_tag) { + return Ok(()); + } + let data = progress_data(progress, bump_progress_seq(&toast_tag))?; + + // Update moves the bar in place, silently; no new toast is shown. + let result = notifier.UpdateWithTagAndGroup( + &data, + &HSTRING::from(toast_tag.as_str()), + &HSTRING::from(GROUP), + )?; + if result == NotificationUpdateResult::Succeeded { + Ok(()) + } else if result == NotificationUpdateResult::NotificationNotFound { + // The toast is gone (dismissed or expired); quietly re-show the whole thing. + show_toast(¬ifier, options, true) + } else { + Err(Error::Unknown) + } +} + +pub(crate) fn cancel(id: &str) -> Result<()> { + let toast_tag = tag(id); + // A fresh show of this id afterwards should alert again. + progress_seqs().lock().unwrap().remove(&toast_tag); + + // Also kill a matching still-pending scheduled toast. + let notifier = ToastNotificationManager::CreateToastNotifierWithId(&aumid())?; + remove_scheduled(¬ifier, Some(&toast_tag))?; + + let history = ToastNotificationManager::History()?; + ignore_not_found(history.RemoveGroupedTagWithId( + &HSTRING::from(toast_tag.as_str()), + &HSTRING::from(GROUP), + &aumid(), + )) +} + +pub(crate) fn cancel_all() -> Result<()> { + progress_seqs().lock().unwrap().clear(); + + let notifier = ToastNotificationManager::CreateToastNotifierWithId(&aumid())?; + remove_scheduled(¬ifier, None)?; + + let history = ToastNotificationManager::History()?; + // Only clear our own group; other apps may share the fallback AUMID. + ignore_not_found(history.RemoveGroupWithId(&HSTRING::from(GROUP), &aumid())) +} + +/// Removes pending scheduled toasts: the one with `tag`, or all of ours. +fn remove_scheduled(notifier: &ToastNotifier, tag: Option<&str>) -> Result<()> { + for scheduled in notifier.GetScheduledToastNotifications()? { + let matches = match tag { + Some(tag) => scheduled + .Tag() + .is_ok_and(|t| t.to_string_lossy() == tag), + // Match our group; if a toast doesn't even expose one, treat it + // as ours, since everything this notifier scheduled came from us. + None => scheduled + .Group() + .map(|group| group.to_string_lossy() == GROUP) + .unwrap_or(true), + }; + if matches { + notifier.RemoveFromSchedule(&scheduled)?; + } + } + Ok(()) +} + +pub(crate) fn request_permission(callback: PermissionCallback, _provisional: bool) -> Result<()> { + // Windows has no permission prompt (provisional or otherwise); just + // report whether toasts are enabled. + callback(Ok(toasts_enabled())); + Ok(()) +} + +pub(crate) fn notification_settings( + _scope: SettingsScope, + callback: SettingsCallback, +) -> Result<()> { + // Windows only exposes the app-wide on/off state, so every scope + // reports the same thing and the finer-grained fields stay None. + // Unlike request_permission's optimistic default, a read-back that + // can't even query the notifier reports the error honestly. + let enabled = match ToastNotificationManager::CreateToastNotifierWithId(&aumid()) + .and_then(|notifier| notifier.Setting()) + { + Ok(setting) => setting == NotificationSetting::Enabled, + Err(error) => { + callback(Err(error.into())); + return Ok(()); + } + }; + callback(Ok(NotificationSettings { + enabled, + urgency: None, + sound_enabled: None, + badge_enabled: None, + customized_by_user: None, + priority_conversation: None, + })); + Ok(()) +} + +pub(crate) fn open_notification_settings(_scope: SettingsScope) -> Result<()> { + // Windows has no public per-app or per-channel settings URI, so every + // scope lands on the system notification settings page. + let uri = Uri::CreateUri(&HSTRING::from("ms-settings:notifications"))?; + // Blocking is fine here; the launch resolves quickly. + if Launcher::LaunchUriAsync(&uri)?.get()? { + Ok(()) + } else { + Err(Error::Unknown) + } +} + +pub(crate) fn init_interaction_listener() -> Result<()> { + // Nothing to set up: interactions arrive via the per-toast event handlers. + Ok(()) +} + +pub(crate) fn set_app_badge(count: u32) -> Result<()> { + let updater = BadgeUpdateManager::CreateBadgeUpdaterForApplicationWithId(&aumid())?; + if count == 0 { + updater.Clear()?; + return Ok(()); + } + let xml = XmlDocument::new()?; + xml.LoadXml(&HSTRING::from(format!(r#""#)))?; + updater.Update(&BadgeNotification::CreateBadgeNotification(&xml)?)?; + Ok(()) +} + +pub(crate) fn active_notification_ids(callback: ActiveIdsCallback) -> Result<()> { + callback(collect_active_ids()); + Ok(()) +} + +/// The ids of our still-showing toasts. Only toasts shown by this run of the +/// app can be translated back from their OS tag; older tags are skipped. +fn collect_active_ids() -> Result> { + let history = ToastNotificationManager::History()?; + let toasts = history.GetHistoryWithId(&aumid())?; + let tags = tag_ids().lock().unwrap(); + let mut ids = Vec::new(); + for toast in toasts { + let Ok(toast_tag) = toast.Tag() else { continue }; + if let Some(id) = tags.get(&toast_tag.to_string_lossy()) { + ids.push(id.clone()); + } + } + Ok(ids) +} + +/// tag -> id for every toast we've shown, so [`collect_active_ids`] can +/// translate the OS's tags back into our notification ids. +fn tag_ids() -> &'static Mutex> { + static TAG_IDS: OnceLock>> = OnceLock::new(); + TAG_IDS.get_or_init(Mutex::default) +} + +fn remember_tag(id: &str) { + tag_ids().lock().unwrap().insert(tag(id), id.to_owned()); +} + +/// Per-tag NotificationData sequence numbers; an entry also means the tag +/// was already shown with progress (so a re-show shouldn't alert). +fn progress_seqs() -> &'static Mutex> { + static SEQS: OnceLock>> = OnceLock::new(); + SEQS.get_or_init(Mutex::default) +} + +/// Bumps and returns the tag's data sequence number (first use: 1). +fn bump_progress_seq(tag: &str) -> u32 { + let mut seqs = progress_seqs().lock().unwrap(); + let seq = seqs.entry(tag.to_owned()).or_insert(0); + *seq += 1; + *seq +} + +/// The data a progress toast's bound XML fields read their values from. +fn progress_data(progress: Progress, sequence: u32) -> Result { + let data = NotificationData::new()?; + data.SetSequenceNumber(sequence)?; + let values = data.Values()?; + values.Insert( + &HSTRING::from("progressValue"), + &HSTRING::from(progress_value(progress)), + )?; + // No status line; the binding just needs the key to exist. + values.Insert(&HSTRING::from("progressStatus"), &HSTRING::new())?; + Ok(data) +} + +/// The progressValue string: "indeterminate" or a 0..1 decimal. +fn progress_value(progress: Progress) -> String { + match progress { + Progress::Indeterminate => "indeterminate".to_owned(), + // total is pre-validated non-zero; past-the-end counts as done. + Progress::Determinate { current, total } => { + format!("{:.4}", f64::from(current.min(total)) / f64::from(total)) + } + } +} + +/// Whether toasts are currently enabled for our AUMID. Optimistically +/// assumes yes if the notifier can't even be created. +fn toasts_enabled() -> bool { + ToastNotificationManager::CreateToastNotifierWithId(&aumid()) + .and_then(|notifier| notifier.Setting()) + .map(|setting| setting == NotificationSetting::Enabled) + .unwrap_or(true) +} + +/// The AUMID our toasts get attributed to: the app-provided id (re-read +/// every time so a later `set_app_id` isn't ignored), else one registered +/// by a shortcut/installer, else the borrowed PowerShell one. +fn aumid() -> HSTRING { + if let Some(id) = crate::app_id() { + return HSTRING::from(id); + } + // Only the fallback is cached; looking it up involves an OS call. + static FALLBACK: OnceLock = OnceLock::new(); + FALLBACK + .get_or_init(|| { + let id = registered_aumid().unwrap_or_else(|| POWERSHELL_AUMID.to_owned()); + HSTRING::from(id) + }) + .clone() +} + +/// The AUMID a shortcut or installer registered for this process, if any. +fn registered_aumid() -> Option { + unsafe { + let pwstr = GetCurrentProcessExplicitAppUserModelID().ok()?; + let id = pwstr.to_hstring().ok().map(|id| id.to_string_lossy()); + CoTaskMemFree(Some(pwstr.as_ptr() as *const _)); + id + } +} + +/// Tags max out at 16 chars before Windows 10 1903, so hash the id down +/// to 16 hex chars. FNV-1a is tiny and stable across runs, which lets a +/// later process still cancel a toast by its id. +fn tag(id: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in id.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +/// Builds the ToastGeneric XML payload for this notification. +/// Windows has no equivalent for conversations (and their message history), +/// event timestamps, lock-screen visibility, or a per-notification +/// Do-Not-Disturb bypass, so those options are ignored here. +fn build_toast_xml(options: &NotificationOptions, quiet: bool) -> Result { + let launch = encode_arguments(&options.id, None, &options.metadata); + let mut xml = String::new(); + let _ = write!(xml, r#"= Duration::from_secs(25)) { + xml.push_str(r#" duration="long""#); + } + xml.push('>'); + + xml.push_str(r#""#); + let texts = [&options.title, &options.body, &options.subtitle]; + for text in texts.into_iter().flatten() { + let _ = write!(xml, "{}", escape_xml(text)); + } + if let Some(image) = &options.image { + let _ = write!( + xml, + r#""#, + escape_xml(&file_uri(image)?), + ); + } + if let Some(progress) = options.progress { + if options.scheduled_time.is_none() { + // Bound to the attached NotificationData, so update_progress + // can move the bar in place. + xml.push_str(r#""#); + } else { + // Scheduled toasts can't carry NotificationData; bake the values in. + let _ = write!( + xml, + r#""#, + progress_value(progress), + ); + } + } + xml.push_str(""); + + // Windows rejects toasts with more than 5 actions or inputs, so drop the extras. + let actions = &options.actions[..options.actions.len().min(5)]; + if !actions.is_empty() { + xml.push_str(""); + // Inputs have to come before all the buttons. + for action in actions { + if action.kind == ActionKind::Reply { + let _ = write!(xml, r#""); + } + } + for action in actions { + let arguments = encode_arguments(&options.id, Some(&action.id), &options.metadata); + let _ = write!( + xml, + r#""); + } + xml.push_str(""); + } + + if quiet { + // Quiet re-shows (progress updates) must not re-alert the user. + xml.push_str(r#""); + Ok(xml) +} + +/// Hooks up the per-toast Activated/Dismissed events that feed the user's +/// interactions back to the app. These must be registered before Show. +fn register_interaction_handlers( + toast: &ToastNotification, + options: &NotificationOptions, +) -> Result<()> { + toast.Activated(&TypedEventHandler::new( + |_sender: &Option, args: &Option| { + // WinRT invokes this; unwinding into it would abort the process. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let Some(args) = args else { return }; + let Ok(args) = args.cast::() else { + return; + }; + let arguments = args + .Arguments() + .map(|arguments| arguments.to_string_lossy()) + .unwrap_or_default(); + let (notification_id, action_id, metadata) = decode_arguments(&arguments); + let kind = match action_id { + None => InteractionKind::Activated, + Some(action_id) => match reply_text(&args, &action_id) { + Some(text) => InteractionKind::Reply { action_id, text }, + None => InteractionKind::Action { id: action_id }, + }, + }; + crate::deliver_interaction(Interaction { + notification_id, + kind, + metadata, + }); + })); + Ok(()) + }, + ))?; + + let notification_id = options.id.clone(); + let metadata = options.metadata.clone(); + toast.Dismissed(&TypedEventHandler::new( + move |_sender: &Option, args: &Option| { + // As above: never unwind into WinRT. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let Some(args) = args else { return }; + // Only user dismissals count; timeouts and app hides aren't interactions. + if args.Reason().is_ok_and(|reason| reason == ToastDismissalReason::UserCanceled) { + crate::deliver_interaction(Interaction { + notification_id: notification_id.clone(), + kind: InteractionKind::Dismissed, + metadata: metadata.clone(), + }); + } + })); + Ok(()) + }, + ))?; + Ok(()) +} + +/// The text the user typed into the quick-reply input, if this activation has any. +fn reply_text(args: &ToastActivatedEventArgs, action_id: &str) -> Option { + let inputs = args.UserInput().ok()?; + let value = inputs.Lookup(&HSTRING::from(action_id)).ok()?; + let text = value.cast::().ok()?.GetString().ok()?; + Some(text.to_string_lossy()) +} + +/// Converts to WinRT's DateTime: 100ns ticks since 1601-01-01, which is +/// 11644473600 seconds before the Unix epoch. +fn datetime(time: SystemTime) -> DateTime { + const UNIX_EPOCH_TICKS: i64 = 116_444_736_000_000_000; + let unix = time.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO); + DateTime { + UniversalTime: UNIX_EPOCH_TICKS.saturating_add((unix.as_nanos() / 100) as i64), + } +} + +/// Windows only takes an absolute expiration time, so add the timeout to now. +fn set_expiration(toast: &ToastNotification, timeout: Duration) -> Result<()> { + let expiration = PropertyValue::CreateDateTime(datetime(SystemTime::now() + timeout))?; + toast.SetExpirationTime(&expiration.cast::>()?)?; + Ok(()) +} + +/// Converts an image path into the `file:///` URI form toast XML wants. +fn file_uri(path: &Path) -> Result { + // `canonicalize` prepends a `\\?\` verbatim prefix; strip it back out. + let path = std::fs::canonicalize(path)?; + let text = path.to_string_lossy(); + let text = text + .strip_prefix(r"\\?\UNC\") + .map(|rest| format!(r"\\{rest}")) + .or_else(|| text.strip_prefix(r"\\?\").map(str::to_owned)) + .unwrap_or_else(|| text.into_owned()); + + let mut uri = String::from("file:///"); + for ch in text.chars() { + match ch { + '\\' => uri.push('/'), + c if c.is_ascii_alphanumeric() || "/:-_.~".contains(c) => uri.push(c), + c => { + let mut buf = [0u8; 4]; + for byte in c.encode_utf8(&mut buf).bytes() { + let _ = write!(uri, "%{byte:02X}"); + } + } + } + } + Ok(uri) +} + +/// Packs the notification id, the pressed action's id (if any), and the +/// metadata into a `k=v&k=v` string; it's all the OS hands back on activation. +fn encode_arguments( + notification_id: &str, + action_id: Option<&str>, + metadata: &[(String, String)], +) -> String { + let mut arguments = format!("id={}", percent_encode(notification_id)); + if let Some(action_id) = action_id { + let _ = write!(arguments, "&action={}", percent_encode(action_id)); + } + for (key, value) in metadata { + let _ = write!( + arguments, + "&m={}:{}", + percent_encode(key), + percent_encode(value), + ); + } + arguments +} + +/// The inverse of [`encode_arguments`]. +fn decode_arguments(arguments: &str) -> (String, Option, Vec<(String, String)>) { + let mut notification_id = String::new(); + let mut action_id = None; + let mut metadata = Vec::new(); + for pair in arguments.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + match key { + "id" => notification_id = percent_decode(value), + "action" => action_id = Some(percent_decode(value)), + "m" => { + if let Some((key, value)) = value.split_once(':') { + metadata.push((percent_decode(key), percent_decode(value))); + } + } + _ => {} + } + } + (notification_id, action_id, metadata) +} + +/// Percent-encodes everything but unreserved URI characters. +fn percent_encode(text: &str) -> String { + let mut encoded = String::with_capacity(text.len()); + for byte in text.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char) + } + _ => { + let _ = write!(encoded, "%{byte:02X}"); + } + } + } + encoded +} + +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = (bytes[i] == b'%') + .then(|| bytes.get(i + 1..i + 3)) + .flatten() + .and_then(|hex| std::str::from_utf8(hex).ok()) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + match hex { + Some(byte) => { + decoded.push(byte); + i += 3; + } + None => { + decoded.push(bytes[i]); + i += 1; + } + } + } + String::from_utf8_lossy(&decoded).into_owned() +} + +/// Escapes text for use in XML content and attribute values. +fn escape_xml(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + // XML 1.0 can't represent these at all (not even escaped), + // and LoadXml would reject the whole toast. + c if (c < ' ' && c != '\t' && c != '\n' && c != '\r') + || c == '\u{FFFE}' + || c == '\u{FFFF}' => + { + escaped.push(' ') + } + _ => escaped.push(ch), + } + } + escaped +} + +/// Canceling something that's already gone is fine. +fn ignore_not_found(result: windows::core::Result<()>) -> Result<()> { + match result { + Ok(()) => Ok(()), + // HRESULT_FROM_WIN32(ERROR_NOT_FOUND) + Err(error) if error.code().0 as u32 == 0x8007_0490 => Ok(()), + Err(error) => Err(error.into()), + } +}