Skip to content

Commit 0d5ddd6

Browse files
committed
feat: add notification-common cross-platform notification abstraction
Unified DSL for sending notifications on Linux, Windows, and macOS behind a single API. Includes per-notification callbacks, up to 5 action buttons, image support, and dismiss handling. - notification-common module with platform dispatchers - Example app tab demonstrating the common API - MkDocs documentation page
1 parent 089a70d commit 0d5ddd6

19 files changed

Lines changed: 1442 additions & 0 deletions
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
# Notification (Common)
2+
3+
Cross-platform notification abstraction that unifies Linux, Windows, and macOS notification APIs behind a single Kotlin DSL. Send notifications with title, message, images, action buttons, and lifecycle callbacks — the module routes to the right platform backend at runtime.
4+
5+
!!! info "Simple subset by design"
6+
This module exposes the **intersection** of what all three platforms support: title, message, large image, small icon, up to 5 action buttons, and lifecycle callbacks. For platform-specific features (progress bars, input fields, scheduling, categories), use the dedicated [Linux](notification-linux.md), [Windows](notification-windows.md), or [macOS](notification-macos.md) modules directly.
7+
8+
## Installation
9+
10+
```kotlin
11+
dependencies {
12+
implementation("io.github.kdroidfilter:nucleus.notification-common:<version>")
13+
}
14+
```
15+
16+
This single dependency pulls in all three platform modules (Linux, Windows, macOS) and `core-runtime` transitively. The module detects the current OS at runtime and delegates to the appropriate backend — non-native libraries are simply unused on other platforms.
17+
18+
## Quick Start
19+
20+
```kotlin
21+
import io.github.kdroidfilter.nucleus.notification.common.*
22+
23+
// Build a notification
24+
val n = notification(
25+
title = "Download Complete",
26+
message = "report.pdf has been saved",
27+
) {
28+
button("Open") { openFile() }
29+
button("Show in Folder") { showInFolder() }
30+
}
31+
32+
// Send it
33+
n.send()
34+
```
35+
36+
## Full Example
37+
38+
```kotlin
39+
import io.github.kdroidfilter.nucleus.notification.common.*
40+
41+
val myNotification = notification(
42+
title = "New Message from Alice",
43+
message = "Hey! Have you seen the latest build?",
44+
largeImage = Res.getUri("drawable/alice_avatar.png"),
45+
smallIcon = Res.getUri("drawable/app_icon.png"),
46+
onActivated = { openConversation("alice") },
47+
onDismissed = { reason -> println("Dismissed: $reason") },
48+
onFailed = { println("Failed to show notification") },
49+
) {
50+
button("Reply") { showReplyDialog("alice") }
51+
button("Archive") { archiveConversation("alice") }
52+
}
53+
54+
// Check availability before sending
55+
if (NotificationManager.isAvailable()) {
56+
when (val result = myNotification.send()) {
57+
is NotificationResult.Success -> {
58+
// Store the handle to dismiss later
59+
val handle = result.handle
60+
// ...
61+
handle.dismiss()
62+
}
63+
is NotificationResult.Failure -> {
64+
println("Could not send: ${result.reason}")
65+
}
66+
}
67+
}
68+
```
69+
70+
## API Reference
71+
72+
### `notification()` — DSL Entry Point
73+
74+
Top-level function that builds a `Notification` instance.
75+
76+
```kotlin
77+
fun notification(
78+
title: String,
79+
message: String = "",
80+
largeImage: String? = null,
81+
smallIcon: String? = null,
82+
onActivated: (() -> Unit)? = null,
83+
onDismissed: ((DismissReason) -> Unit)? = null,
84+
onFailed: (() -> Unit)? = null,
85+
buttons: (NotificationButtonBuilder.() -> Unit)? = null,
86+
): Notification
87+
```
88+
89+
| Parameter | Type | Default | Description |
90+
|---|---|---|---|
91+
| `title` | `String` | *(required)* | Notification title. |
92+
| `message` | `String` | `""` | Body text. |
93+
| `largeImage` | `String?` | `null` | URI to a large image (hero image on Windows, image hint on Linux, attachment on macOS). |
94+
| `smallIcon` | `String?` | `null` | URI to a small icon (app logo on Windows, app icon on Linux, ignored on macOS). |
95+
| `onActivated` | `(() -> Unit)?` | `null` | Called when the user clicks the notification body. |
96+
| `onDismissed` | `((DismissReason) -> Unit)?` | `null` | Called when the notification is dismissed. |
97+
| `onFailed` | `(() -> Unit)?` | `null` | Called if the notification fails to display. |
98+
| `buttons` | DSL block | `null` | Builder block to add up to 5 action buttons. |
99+
100+
### `NotificationButtonBuilder`
101+
102+
Available inside the `notification { }` trailing lambda.
103+
104+
| Method | Description |
105+
|---|---|
106+
| `button(title: String, onClick: () -> Unit)` | Add an action button. Maximum 5 buttons (Windows limit). |
107+
108+
---
109+
110+
### `Notification`
111+
112+
Immutable notification object returned by the `notification()` function. The same instance can be sent multiple times — each call creates a new system notification.
113+
114+
| Method | Returns | Description |
115+
|---|---|---|
116+
| `send()` | `NotificationResult` | Sends the notification to the OS. |
117+
118+
---
119+
120+
### `NotificationResult`
121+
122+
Sealed class returned by `send()`.
123+
124+
| Subclass | Properties | Description |
125+
|---|---|---|
126+
| `Success` | `handle: NotificationHandle` | Notification sent successfully. |
127+
| `Failure` | `reason: String` | Notification could not be sent. |
128+
129+
---
130+
131+
### `NotificationHandle`
132+
133+
Opaque handle to a sent notification.
134+
135+
| Method | Description |
136+
|---|---|
137+
| `dismiss()` | Programmatically close the notification if still visible. |
138+
139+
---
140+
141+
### `NotificationManager`
142+
143+
Singleton facade for platform detection and notification dispatch.
144+
145+
| Method | Returns | Description |
146+
|---|---|---|
147+
| `isAvailable()` | `Boolean` | `true` if the current platform's notification module is on the classpath and functional. |
148+
| `initialize()` | `Unit` | Eagerly initialize the notification subsystem (Windows only — called lazily on first `send()` otherwise). |
149+
| `send(notification)` | `NotificationResult` | Send a notification. Prefer using `notification.send()` directly. |
150+
151+
---
152+
153+
### `DismissReason`
154+
155+
Unified enum for why a notification was dismissed.
156+
157+
| Value | Description | Linux | Windows | macOS |
158+
|---|---|---|---|---|
159+
| `USER_DISMISSED` | User explicitly dismissed | `DISMISSED` | `USER_CANCELED` | Custom dismiss action |
160+
| `TIMED_OUT` | Auto-expired after timeout | `EXPIRED` | `TIMED_OUT` ||
161+
| `APPLICATION` | Closed programmatically | `CLOSED` | `APPLICATION_HIDDEN` ||
162+
| `UNKNOWN` | Could not be determined | `UNDEFINED` |||
163+
164+
---
165+
166+
## Platform Mapping
167+
168+
How each parameter maps to platform-specific APIs:
169+
170+
| Common | Linux | Windows | macOS |
171+
|---|---|---|---|
172+
| `title` | `summary` | First `AdaptiveText` (bold) | `content.title` |
173+
| `message` | `body` | Second `AdaptiveText` | `content.body` |
174+
| `largeImage` | `hints.imagePath` | Hero image (top banner) | `attachments[0]` |
175+
| `smallIcon` | `appIcon` | App logo override (left of text) | Ignored (uses bundle icon) |
176+
| `buttons` | `actions` list | `ToastButton` list | Auto-generated `NotificationCategory` |
177+
| `onActivated` | `onActionInvoked` with `"default"` key | `onActivated` with empty arguments | `didReceive` with `DEFAULT_ACTION` |
178+
| `onDismissed` | `onClosed` signal | `onDismissed` event | Requires `CUSTOM_DISMISS_ACTION` |
179+
| `onFailed` | `notify()` returns 0 | `onFailed` event | `add()` callback error |
180+
181+
## Platform Details
182+
183+
### Windows
184+
185+
- **Initialization**: `WindowsNotificationCenter.initialize()` is called automatically on the first `send()`. Call `NotificationManager.initialize()` explicitly for early setup.
186+
- **Tag/Group**: Each notification gets a unique tag (`n1`, `n2`, ...) under the `"ncm"` group.
187+
- **Images**: `largeImage` maps to a hero image at the top of the toast. `smallIcon` maps to the app logo override (displayed left of the text). Both accept `file:///` URIs and HTTP URLs.
188+
- **Buttons**: Up to 5, rendered as standard toast action buttons.
189+
190+
### macOS
191+
192+
- **App bundle required**: Notifications only work inside a packaged `.app` bundle (e.g. via `./gradlew runDistributable`). `isAvailable()` returns `false` when running via `./gradlew run`.
193+
- **Authorization**: The user must have granted notification permissions. The common module does **not** auto-request authorization — use `NotificationCenter.requestAuthorization()` from the macOS module before sending.
194+
- **Buttons**: Require pre-registered `NotificationCategory` objects. The common module handles this automatically — it generates and caches categories per unique button configuration.
195+
- **Dismiss callback**: macOS does not natively fire dismiss events. The common module enables `CUSTOM_DISMISS_ACTION` on generated categories so `onDismissed` fires when the user explicitly dismisses.
196+
- **Small icon**: Ignored — macOS always uses the app icon from the bundle.
197+
- **Large image**: Mapped to a notification attachment (displayed as a thumbnail).
198+
199+
### Linux
200+
201+
- **No initialization needed**: The D-Bus connection is established automatically.
202+
- **Images**: `largeImage` maps to the `imagePath` hint (icon name or `file://` URI). `smallIcon` maps to `appIcon`. See the [Linux notification docs](notification-linux.md#icons) for icon priority.
203+
- **Default action**: A `"default"` action is automatically added when `onActivated` is set, so clicking the notification body triggers the callback.
204+
- **All callbacks on Swing EDT**: Safe to update Compose state directly from callbacks.
205+
206+
## Compose Desktop Integration
207+
208+
```kotlin
209+
@Composable
210+
fun NotificationDemo() {
211+
var lastResult by remember { mutableStateOf<NotificationResult?>(null) }
212+
213+
Button(onClick = {
214+
val n = notification(
215+
title = "Build Finished",
216+
message = "nucleus-1.3.0 compiled in 42s",
217+
onActivated = { println("Notification clicked") },
218+
) {
219+
button("View Logs") { openLogs() }
220+
}
221+
lastResult = n.send()
222+
}) {
223+
Text("Send Notification")
224+
}
225+
226+
lastResult?.let { result ->
227+
when (result) {
228+
is NotificationResult.Success -> Text("Sent!")
229+
is NotificationResult.Failure -> Text("Failed: ${result.reason}")
230+
}
231+
}
232+
}
233+
```
234+
235+
!!! tip "Getting the best experience across platforms"
236+
Always provide both `largeImage` and `smallIcon` for the richest display. On platforms that don't support one (e.g. `smallIcon` on macOS), it is silently ignored.
237+
238+
## Architecture
239+
240+
The module uses a dispatcher pattern inspired by `taskbar-progress`:
241+
242+
```
243+
NotificationManager (singleton)
244+
└─ DispatcherFactory (selects by os.name)
245+
├─ LinuxDispatcher → LinuxNotificationCenter
246+
├─ WindowsDispatcher → WindowsNotificationCenter
247+
└─ MacOsDispatcher → NotificationCenter (macOS)
248+
```
249+
250+
Each dispatcher:
251+
252+
1. Checks for the platform module on the classpath via `Class.forName` (no `NoClassDefFoundError` if absent)
253+
2. Registers **one global listener** on the platform's notification center
254+
3. Routes callbacks to per-notification lambdas via a `ConcurrentHashMap<platformId, callbacks>` registry
255+
4. Cleans up callback entries on dismiss/failure events
256+
257+
## ProGuard
258+
259+
No additional ProGuard rules are needed for `notification-common` itself. Ensure the platform module rules are applied — see [Linux](notification-linux.md#proguard), [Windows](notification-windows.md#proguard), [macOS](notification-macos.md#proguard).
260+
261+
## GraalVM
262+
263+
No additional GraalVM metadata is needed for `notification-common`. The platform modules ship their own `reachability-metadata.json`. See [Linux](notification-linux.md#graalvm), [Windows](notification-windows.md#graalvm), [macOS](notification-macos.md#graalvm).

example/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies {
2929
implementation(project(":decorated-window-jni"))
3030
implementation(project(":energy-manager"))
3131
implementation(project(":taskbar-progress"))
32+
implementation(project(":notification-common"))
3233
implementation(project(":notification-macos"))
3334
implementation(project(":notification-linux"))
3435
implementation(project(":notification-windows"))

0 commit comments

Comments
 (0)