@tokamakdev/plugin-notifications 0.1.0-beta.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # @tokamakdev/plugin-notifications
2
+
3
+ Local and push notifications for tokamak applications and the web.
4
+
5
+ ```sh
6
+ npm install @tokamakdev/plugin-notifications
7
+ ```
8
+
9
+ ```ts
10
+ import { notifications } from "@tokamakdev/plugin-notifications";
11
+
12
+ await notifications.requestPermission(); // "granted" | "denied" | "prompt"
13
+
14
+ await notifications.show({ id: "download-42", title: "Download finished", body: "report.pdf" });
15
+ await notifications.schedule({ id: "reminder-7", title: "Check in", at: Date.parse("2026-10-01T09:00:00Z") });
16
+
17
+ const subscription = await notifications.subscribe({ applicationServerKey: VAPID_PUBLIC_KEY });
18
+ await fetch("/api/subscriptions", { method: "POST", body: JSON.stringify(subscription) });
19
+
20
+ notifications.onMessage((message) => console.log(message.data));
21
+ notifications.onNotificationOpened((opened) => console.log(opened.id, opened.source));
22
+ notifications.onSubscriptionChange((subscription) => {
23
+ void fetch("/api/subscriptions", { method: "POST", body: JSON.stringify(subscription) });
24
+ });
25
+ ```
26
+
27
+ ## Permission
28
+
29
+ `permission()` reports whether the app may show notifications and
30
+ `requestPermission()` asks the user. No other method asks. On Android before
31
+ 13, notifications are allowed unless the user turns them off.
32
+
33
+ ## Local notifications
34
+
35
+ - `show(notification)` shows a notification now, even while the page is
36
+ visible.
37
+ - `schedule(notification)` shows it at `at`, in milliseconds since the epoch,
38
+ or now when `at` has passed. Scheduled notifications survive app and device
39
+ restarts. Android may delay them by several minutes while the device is idle.
40
+ iOS and macOS keep at most 64; scheduling more rejects with
41
+ `QuotaExceededError`. The web does not support scheduling.
42
+ - `getScheduled()` lists scheduled notifications not yet shown, and
43
+ `getDelivered()` lists shown notifications still in the notification centre.
44
+ - `remove(id)` removes a scheduled or shown notification. On Android, the id
45
+ of a push notification FCM showed is its `tag`.
46
+
47
+ Showing or scheduling a notification with the `id` of another replaces it.
48
+
49
+ ## Push notifications
50
+
51
+ `subscribe()` subscribes to push messages and returns where the app's server
52
+ sends them. It returns the existing subscription when there is one.
53
+
54
+ | Platform | Subscription | Push service |
55
+ |---|---|---|
56
+ | iOS, macOS | `{ service: "apns", token, environment }` | APNs, in `environment` (`development` or `production`) |
57
+ | Android | `{ service: "fcm", token }` | Firebase Cloud Messaging HTTP v1 |
58
+ | Web | `{ service: "webpush", endpoint, keys }` | Web Push, with the app's VAPID key pair |
59
+
60
+ Push notifications that arrive while the page is visible reach `onMessage`.
61
+ They are also shown when `subscribe` was given `showInForeground: true`, on
62
+ iOS, macOS and Android. The web shows every push notification.
63
+ `onSubscriptionChange` reports a replacement subscription, for example when FCM
64
+ rotates a token.
65
+
66
+ ### Declarations
67
+
68
+ Local notifications need no declarations. Push needs these:
69
+
70
+ | Platform | Declaration |
71
+ |---|---|
72
+ | iOS | `aps-environment` in the app's entitlements file, set with `ios.entitlements`. The value may be `development`: signing uses the provisioning profile's value. Push requires a paid Apple Developer Program team. |
73
+ | macOS | `com.apple.developer.aps-environment` in the `macos.entitlements` file, and a team-signed build (`macos.team-id`). |
74
+ | Android | The app's Firebase project values as `<meta-data>` in its `android.manifest` file, from the Firebase console's project settings. |
75
+ | Web | A service worker that uses the helper below, and the app's VAPID public key as `applicationServerKey`. |
76
+
77
+ ```xml
78
+ <!-- iOS entitlements file -->
79
+ <plist version="1.0">
80
+ <dict>
81
+ <key>aps-environment</key>
82
+ <string>development</string>
83
+ </dict>
84
+ </plist>
85
+ ```
86
+
87
+ ```xml
88
+ <!-- Android manifest file -->
89
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
90
+ <application>
91
+ <meta-data android:name="com.tokamak.notifications.fcm.application-id" android:value="1:1234567890:android:0123456789abcdef" />
92
+ <meta-data android:name="com.tokamak.notifications.fcm.project-id" android:value="my-project" />
93
+ <meta-data android:name="com.tokamak.notifications.fcm.api-key" android:value="AIza..." />
94
+ </application>
95
+ </manifest>
96
+ ```
97
+
98
+ Without its declaration, `subscribe` rejects with `InvalidStateError`, and in
99
+ a macOS build without a team with `NotSupportedError`. The Firebase values
100
+ identify the project and are not secret.
101
+
102
+ The iOS build declares the `remote-notification` background mode. An
103
+ `ios.plist` file that sets `UIBackgroundModes` replaces the plugin's value, so
104
+ it must include `remote-notification`.
105
+
106
+ ### Messages
107
+
108
+ The server sends each service's payload, and the plugin reports it as a
109
+ `Message`:
110
+
111
+ | Service | `id` | `title`, `body` | `data` |
112
+ |---|---|---|---|
113
+ | APNs | The notification's identifier | `aps.alert.title`, `aps.alert.body` | Every top-level key except `aps` |
114
+ | FCM | The message ID | `notification.title`, `notification.body` | The `data` map |
115
+ | Web Push | `id` in the JSON payload | `title`, `body` in the payload | `data` in the payload |
116
+
117
+ A Web Push payload is JSON: `{ "id": "...", "title": "...", "body": "...", "data": {} }`.
118
+ Every field is optional.
119
+
120
+ ### Data-only messages
121
+
122
+ A message without a title or body runs the Worker's `push` handler in native
123
+ builds, whether the app is in the foreground, in the background, or started by
124
+ the message:
125
+
126
+ ```js
127
+ export default {
128
+ async fetch(request, env, ctx) {
129
+ // ...
130
+ },
131
+
132
+ async push(message, env, ctx) {
133
+ const item = await fetch(`https://api.example.com/items/${message.data.id}`);
134
+ // Return a notification to show it, or return nothing.
135
+ return { id: `item-${message.data.id}`, title: "New item", body: (await item.json()).summary };
136
+ },
137
+ };
138
+ ```
139
+
140
+ `message` is the `Message`; `env` and `ctx` are the Worker's usual arguments.
141
+ A returned notification is shown as `show` shows one. The page also receives
142
+ the message through `onMessage` while it is loaded. Cloudflare never calls
143
+ `push`, and `tok dev` does not run it.
144
+
145
+ The platforms limit background delivery:
146
+
147
+ | Platform | The server sends | Limits |
148
+ |---|---|---|
149
+ | iOS | `content-available: 1` with `apns-push-type: background` and `apns-priority: 5` | The system throttles delivery and may drop messages, and delivers nothing after the user force-quits the app until they open it. The plugin stops the handler after 25 seconds, within the system's 30. |
150
+ | macOS | As iOS | Delivered while the app is running. |
151
+ | Android | A `data` message with `priority: high` | Nothing is delivered after the app is force-stopped. The plugin stops the handler after 8 seconds, within FCM's 10, which include starting the app. |
152
+
153
+ The web does not support data-only messages: browsers require every push
154
+ message to show a notification.
155
+
156
+ ## Opened notifications
157
+
158
+ `onNotificationOpened` receives each notification the user opens, local or
159
+ push. The plugin holds notifications opened before a page listens, including
160
+ the one that launched the app, and delivers them to the first listener.
161
+ Android does not pass the title or body of an FCM notification the system
162
+ showed, so those are null.
163
+
164
+ ## Web
165
+
166
+ The web implementation needs a service worker for everything but `show`. Add
167
+ the helpers to the app's own service worker:
168
+
169
+ ```js
170
+ import {
171
+ handleNotificationClick,
172
+ handlePush,
173
+ handleSubscriptionChange,
174
+ } from "@tokamakdev/plugin-notifications/service-worker";
175
+
176
+ self.addEventListener("push", (event) => handlePush(event));
177
+ self.addEventListener("notificationclick", (event) => handleNotificationClick(event));
178
+ self.addEventListener("pushsubscriptionchange", (event) => handleSubscriptionChange(event));
179
+ ```
180
+
181
+ - `handlePush` shows the payload's notification and forwards the message to
182
+ open pages.
183
+ - `handleNotificationClick` focuses an open page, or opens `data.url` (default
184
+ `/`), and holds the notification for `onNotificationOpened`. It returns false
185
+ for notifications the plugin did not show.
186
+ - `handleSubscriptionChange` forwards a replacement subscription to open pages.
187
+
188
+ Safari supports Web Push on macOS 13 and later, and on iOS and iPadOS 16.4 and
189
+ later only for web apps added to the Home Screen.
190
+
191
+ ## Android icon
192
+
193
+ Android shows notifications with a monochrome icon. Add one as
194
+ `drawable/tokamak_notification` in the app's Android resource directory, set
195
+ with `android.icon`. Without it, the plugin uses the launcher icon, which
196
+ Android draws as a solid shape.
197
+
198
+ ## Errors
199
+
200
+ - `NotAllowedError`: permission has not been granted.
201
+ - `NotSupportedError`: the platform or build cannot perform the call.
202
+ - `InvalidStateError`: the app has not made a declaration push requires.
203
+ - `QuotaExceededError`: the platform's limit on scheduled notifications is
204
+ reached.
205
+ - `OperationError`: a platform or push service failure.
206
+ - `TypeError`: an invalid argument.
@@ -0,0 +1,30 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <application>
3
+ <provider
4
+ android:name="com.tokamak.plugins.notifications.TokamakFirebaseInitializer"
5
+ android:authorities="${applicationId}.tokamak-firebase"
6
+ android:exported="false" />
7
+ <service
8
+ android:name="com.tokamak.plugins.notifications.TokamakPushService"
9
+ android:exported="false">
10
+ <intent-filter>
11
+ <action android:name="com.google.firebase.MESSAGING_EVENT" />
12
+ </intent-filter>
13
+ </service>
14
+ <receiver
15
+ android:name="com.tokamak.plugins.notifications.TokamakAlarmReceiver"
16
+ android:exported="false" />
17
+ <receiver
18
+ android:name="com.tokamak.plugins.notifications.TokamakBootReceiver"
19
+ android:exported="false">
20
+ <intent-filter>
21
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
22
+ <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
23
+ </intent-filter>
24
+ </receiver>
25
+ <meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
26
+ <meta-data
27
+ android:name="com.google.firebase.messaging.default_notification_channel_id"
28
+ android:value="tokamak.notifications" />
29
+ </application>
30
+ </manifest>
@@ -0,0 +1,47 @@
1
+ package com.tokamak.plugins.notifications
2
+
3
+ import com.tokamak.runtime.TokamakPluginError
4
+ import org.json.JSONObject
5
+
6
+ /** A notification the page asked to show or schedule. */
7
+ internal class Content(
8
+ val id: String,
9
+ val title: String,
10
+ val body: String?,
11
+ val data: JSONObject,
12
+ /** Milliseconds since the epoch; null to show now. */
13
+ val at: Long?,
14
+ ) {
15
+ fun toJson(): JSONObject =
16
+ JSONObject()
17
+ .put("id", id)
18
+ .put("title", title)
19
+ .put("data", data)
20
+ .apply {
21
+ body?.let { put("body", it) }
22
+ at?.let { put("at", it) }
23
+ }
24
+
25
+ companion object {
26
+ fun parse(arguments: Any?, scheduled: Boolean): Content {
27
+ val json = arguments as? JSONObject ?: JSONObject()
28
+ val id = json.opt("id") as? String
29
+ if (id.isNullOrEmpty()) throw typeError("id must be a non-empty string")
30
+ val title = json.opt("title") as? String ?: throw typeError("title must be a string")
31
+ val at = if (scheduled) scheduledTime(json) else null
32
+ return Content(id, title, json.opt("body") as? String, json.optJSONObject("data") ?: JSONObject(), at)
33
+ }
34
+
35
+ fun fromJson(json: JSONObject): Content = parse(json, json.has("at"))
36
+
37
+ private fun scheduledTime(json: JSONObject): Long {
38
+ val at = (json.opt("at") as? Number)?.toDouble()
39
+ if (at == null || !at.isFinite()) {
40
+ throw typeError("at must be a number of milliseconds since the epoch")
41
+ }
42
+ return at.toLong()
43
+ }
44
+
45
+ private fun typeError(message: String) = TokamakPluginError("TypeError", message)
46
+ }
47
+ }
@@ -0,0 +1,287 @@
1
+ package com.tokamak.plugins.notifications
2
+
3
+ import android.Manifest
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.content.pm.PackageManager
7
+ import android.os.Build
8
+ import android.util.Log
9
+ import com.google.firebase.FirebaseApp
10
+ import com.google.firebase.messaging.FirebaseMessaging
11
+ import com.tokamak.runtime.TokamakHost
12
+ import com.tokamak.runtime.TokamakPlugin
13
+ import com.tokamak.runtime.TokamakPluginError
14
+ import com.tokamak.runtime.TokamakPluginReply
15
+ import org.json.JSONArray
16
+ import org.json.JSONObject
17
+ import org.json.JSONTokener
18
+
19
+ private const val PREFERENCES = "tokamak.notifications"
20
+ private const val ASKED = "asked"
21
+ private const val SUBSCRIBED = "subscribed"
22
+ private const val TOKEN = "token"
23
+ private const val SHOW_IN_FOREGROUND = "show-in-foreground"
24
+ private const val PERMISSION_REQUEST = 0x4E07
25
+
26
+ /** FCM allows about 10 seconds for a message, including starting the app. */
27
+ private const val PUSH_TIMEOUT_MILLIS = 8_000L
28
+ private const val FCM_MESSAGE_ID_EXTRA = "google.message_id"
29
+ private val LISTENERS = setOf("onMessage", "onNotificationOpened", "onSubscriptionChange")
30
+
31
+ class TokamakNotificationsPlugin(
32
+ private val host: TokamakHost,
33
+ ) : TokamakPlugin {
34
+ override val id = "notifications"
35
+
36
+ private val context = host.context
37
+ private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
38
+ private val notifier = Notifier(context)
39
+ private val schedule = Schedule(context)
40
+ private val listeners = mutableMapOf<String, MutableMap<Int, TokamakPluginReply>>()
41
+ private var nextListener = 0
42
+
43
+ /** Notifications opened while no page listened for them. */
44
+ private val heldOpened = mutableListOf<JSONObject>()
45
+ private val permissionReplies = mutableListOf<TokamakPluginReply>()
46
+
47
+ init {
48
+ notifier.createChannel()
49
+ }
50
+
51
+ override fun call(method: String, arguments: Any?, reply: TokamakPluginReply) {
52
+ runCatching {
53
+ when (method) {
54
+ "permission" -> reply(Result.success(permission()))
55
+ "requestPermission" -> requestPermission(reply)
56
+ "show" -> show(Content.parse(arguments, scheduled = false), reply)
57
+ "schedule" -> schedule(Content.parse(arguments, scheduled = true), reply)
58
+ "getScheduled" -> reply(Result.success(JSONArray(schedule.all().map(Content::toJson))))
59
+ "getDelivered" -> reply(Result.success(JSONArray(notifier.delivered())))
60
+ "remove" -> remove(identifier(arguments), reply)
61
+ "subscribe" -> subscribe(arguments, reply)
62
+ "getSubscription" -> reply(Result.success(subscription()))
63
+ "unsubscribe" -> unsubscribe(reply)
64
+ else -> super.call(method, arguments, reply)
65
+ }
66
+ }.onFailure { reply(Result.failure(pluginError(it))) }
67
+ }
68
+
69
+ override fun subscribe(
70
+ method: String,
71
+ arguments: Any?,
72
+ reply: TokamakPluginReply,
73
+ ): () -> Unit {
74
+ if (method !in LISTENERS) return super.subscribe(method, arguments, reply)
75
+ val key = nextListener++
76
+ listeners.getOrPut(method, ::mutableMapOf)[key] = reply
77
+ if (method == "onNotificationOpened") {
78
+ heldOpened.forEach { reply(Result.success(it)) }
79
+ heldOpened.clear()
80
+ }
81
+ return { listeners[method]?.remove(key) }
82
+ }
83
+
84
+ override fun onRequestPermissionsResult(
85
+ requestCode: Int,
86
+ permissions: Array<out String>,
87
+ grantResults: IntArray,
88
+ ) {
89
+ if (requestCode != PERMISSION_REQUEST) return
90
+ val permission = permission()
91
+ permissionReplies.forEach { it(Result.success(permission)) }
92
+ permissionReplies.clear()
93
+ }
94
+
95
+ /** Delivers a notification the user opened, local or from FCM, that started the activity. */
96
+ override fun onIntent(intent: Intent) {
97
+ val opened =
98
+ intent.getStringExtra(OPENED_EXTRA)?.let(::JSONObject)
99
+ ?: intent.getStringExtra(FCM_MESSAGE_ID_EXTRA)?.let { openedPush(intent, it) }
100
+ ?: return
101
+ intent.removeExtra(OPENED_EXTRA)
102
+ intent.removeExtra(FCM_MESSAGE_ID_EXTRA)
103
+ emit("onNotificationOpened", opened)
104
+ }
105
+
106
+ /** Reports a replaced token; `subscribe` reports the first. */
107
+ internal fun onNewToken(token: String) {
108
+ val previous = preferences.getString(TOKEN, null) ?: return
109
+ if (!preferences.getBoolean(SUBSCRIBED, false) || token == previous) return
110
+ preferences.edit().putString(TOKEN, token).apply()
111
+ context.mainExecutor.execute { emit("onSubscriptionChange", fcmSubscription(token)) }
112
+ }
113
+
114
+ /**
115
+ * Delivers an FCM message to the page. Runs the Worker's `push` handler for a data-only
116
+ * message and shows the notification it returns. FCM calls this off the main thread.
117
+ */
118
+ internal fun onMessageReceived(message: JSONObject, visible: Boolean) {
119
+ context.mainExecutor.execute { emit("onMessage", message) }
120
+ if (visible) {
121
+ if (preferences.getBoolean(SHOW_IN_FOREGROUND, false)) notifier.post(content(message), "push")
122
+ return
123
+ }
124
+ runCatching { host.dispatch("push", message.toString(), PUSH_TIMEOUT_MILLIS) }
125
+ .onSuccess { result -> result?.let(::showReturned) }
126
+ .onFailure { Log.w("tokamak", "the Worker's push handler failed", it) }
127
+ }
128
+
129
+ private fun showReturned(result: String) {
130
+ val returned = JSONTokener(result).nextValue() as? JSONObject ?: return
131
+ runCatching { notifier.post(Content.parse(returned, scheduled = false), "local") }
132
+ .onFailure { Log.w("tokamak", "the Worker's push handler returned an invalid notification", it) }
133
+ }
134
+
135
+ private fun emit(method: String, value: JSONObject) {
136
+ val current = listeners[method].orEmpty().values.toList()
137
+ if (method == "onNotificationOpened" && current.isEmpty()) heldOpened += value
138
+ current.forEach { it(Result.success(value)) }
139
+ }
140
+
141
+ private fun permission(): String =
142
+ when {
143
+ notifier.enabled -> "granted"
144
+ canPrompt() -> "prompt"
145
+ else -> "denied"
146
+ }
147
+
148
+ /** Whether asking shows the system prompt, which Android 13 and later show until the user refuses twice. */
149
+ private fun canPrompt(): Boolean {
150
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
151
+ val permission = Manifest.permission.POST_NOTIFICATIONS
152
+ if (context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) return false
153
+ return !preferences.getBoolean(ASKED, false) ||
154
+ host.activity?.shouldShowRequestPermissionRationale(permission) == true
155
+ }
156
+
157
+ private fun requestPermission(reply: TokamakPluginReply) {
158
+ val activity = host.activity
159
+ if (
160
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
161
+ activity == null ||
162
+ permission() != "prompt"
163
+ ) {
164
+ reply(Result.success(permission()))
165
+ return
166
+ }
167
+ preferences.edit().putBoolean(ASKED, true).apply()
168
+ permissionReplies += reply
169
+ if (permissionReplies.size == 1) {
170
+ activity.requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), PERMISSION_REQUEST)
171
+ }
172
+ }
173
+
174
+ private fun show(content: Content, reply: TokamakPluginReply) {
175
+ requireEnabled()
176
+ notifier.post(content, "local")
177
+ reply(Result.success(null))
178
+ }
179
+
180
+ private fun schedule(content: Content, reply: TokamakPluginReply) {
181
+ requireEnabled()
182
+ if ((content.at ?: 0) <= System.currentTimeMillis()) {
183
+ notifier.post(content, "local")
184
+ } else {
185
+ schedule.add(content)
186
+ }
187
+ reply(Result.success(null))
188
+ }
189
+
190
+ private fun remove(id: String, reply: TokamakPluginReply) {
191
+ schedule.remove(id)
192
+ notifier.remove(id)
193
+ reply(Result.success(null))
194
+ }
195
+
196
+ private fun subscribe(arguments: Any?, reply: TokamakPluginReply) {
197
+ val messaging = messaging() ?: throw invalidState(
198
+ "Push requires the app's Firebase project values in its Android manifest",
199
+ )
200
+ val showInForeground = (arguments as? JSONObject)?.optBoolean("showInForeground") ?: false
201
+ preferences.edit()
202
+ .putBoolean(SUBSCRIBED, true)
203
+ .putBoolean(SHOW_IN_FOREGROUND, showInForeground)
204
+ .apply()
205
+ messaging.isAutoInitEnabled = true
206
+ messaging.token.addOnCompleteListener { task ->
207
+ when {
208
+ !preferences.getBoolean(SUBSCRIBED, false) ->
209
+ reply(Result.failure(TokamakPluginError("AbortError", "unsubscribe was called")))
210
+ task.isSuccessful -> {
211
+ preferences.edit().putString(TOKEN, task.result).apply()
212
+ reply(Result.success(fcmSubscription(task.result)))
213
+ }
214
+ else -> reply(Result.failure(operationError(task.exception)))
215
+ }
216
+ }
217
+ }
218
+
219
+ private fun subscription(): JSONObject? {
220
+ if (!preferences.getBoolean(SUBSCRIBED, false)) return null
221
+ return preferences.getString(TOKEN, null)?.let(::fcmSubscription)
222
+ }
223
+
224
+ private fun unsubscribe(reply: TokamakPluginReply) {
225
+ preferences.edit().remove(SUBSCRIBED).remove(TOKEN).remove(SHOW_IN_FOREGROUND).apply()
226
+ val messaging = messaging() ?: return reply(Result.success(null))
227
+ messaging.isAutoInitEnabled = false
228
+ messaging.deleteToken().addOnCompleteListener { task ->
229
+ reply(if (task.isSuccessful) Result.success(null) else Result.failure(operationError(task.exception)))
230
+ }
231
+ }
232
+
233
+ private fun messaging(): FirebaseMessaging? =
234
+ if (FirebaseApp.getApps(context).isEmpty()) null else FirebaseMessaging.getInstance()
235
+
236
+ /** A received push message's notification, shown as FCM would show it. */
237
+ private fun content(message: JSONObject): Content =
238
+ Content(
239
+ message.getString("id"),
240
+ message.opt("title") as? String ?: "",
241
+ message.opt("body") as? String,
242
+ message.getJSONObject("data"),
243
+ null,
244
+ )
245
+
246
+ private fun fcmSubscription(token: String): JSONObject =
247
+ JSONObject().put("service", "fcm").put("token", token)
248
+
249
+ /** An opened FCM notification: FCM passes its data map, but not its title or body. */
250
+ private fun openedPush(intent: Intent, messageId: String): JSONObject {
251
+ val data = JSONObject()
252
+ intent.extras?.keySet().orEmpty()
253
+ .filterNot { it.startsWith("google.") || it.startsWith("gcm.") || it in FCM_KEYS }
254
+ .forEach { key -> intent.getStringExtra(key)?.let { data.put(key, it) } }
255
+ return JSONObject()
256
+ .put("id", messageId)
257
+ .put("title", JSONObject.NULL)
258
+ .put("body", JSONObject.NULL)
259
+ .put("data", data)
260
+ .put("source", "push")
261
+ }
262
+
263
+ private fun requireEnabled() {
264
+ if (!notifier.enabled) {
265
+ throw TokamakPluginError("NotAllowedError", "Notification permission has not been granted")
266
+ }
267
+ }
268
+
269
+ private fun identifier(arguments: Any?): String {
270
+ val id = (arguments as? JSONObject)?.opt("id") as? String
271
+ if (id.isNullOrEmpty()) throw TokamakPluginError("TypeError", "id must be a non-empty string")
272
+ return id
273
+ }
274
+
275
+ private companion object {
276
+ /** Extras FCM adds to the launch intent of an opened notification. */
277
+ val FCM_KEYS = setOf("from", "collapse_key")
278
+
279
+ fun invalidState(message: String) = TokamakPluginError("InvalidStateError", message)
280
+
281
+ fun operationError(error: Throwable?) =
282
+ TokamakPluginError("OperationError", error?.message ?: "The push service failed")
283
+
284
+ fun pluginError(error: Throwable): TokamakPluginError =
285
+ error as? TokamakPluginError ?: operationError(error)
286
+ }
287
+ }
@@ -0,0 +1,91 @@
1
+ package com.tokamak.plugins.notifications
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.PendingIntent
7
+ import android.content.Context
8
+ import android.os.Bundle
9
+ import org.json.JSONObject
10
+
11
+ internal const val CHANNEL = "tokamak.notifications"
12
+ internal const val OPENED_EXTRA = "com.tokamak.notifications.opened"
13
+ private const val DATA_EXTRA = "com.tokamak.notifications.data"
14
+
15
+ /** The plugin and FCM post every notification with this ID, telling them apart by tag. */
16
+ private const val NOTIFICATION_ID = 0
17
+
18
+ /** Posts, lists and removes the app's notifications on the default channel. */
19
+ internal class Notifier(private val context: Context) {
20
+ private val manager = context.getSystemService(NotificationManager::class.java)
21
+
22
+ val enabled: Boolean
23
+ get() = manager.areNotificationsEnabled()
24
+
25
+ fun createChannel() {
26
+ manager.createNotificationChannel(
27
+ NotificationChannel(CHANNEL, "Notifications", NotificationManager.IMPORTANCE_DEFAULT),
28
+ )
29
+ }
30
+
31
+ /** Shows [content], replacing a notification with the same ID; opening it launches the app. */
32
+ fun post(content: Content, source: String) {
33
+ createChannel()
34
+ val opened = opened(content, source)
35
+ val notification =
36
+ Notification.Builder(context, CHANNEL)
37
+ .setSmallIcon(icon())
38
+ .setContentTitle(content.title)
39
+ .setContentText(content.body)
40
+ .setAutoCancel(true)
41
+ .setContentIntent(launch(content.id, opened))
42
+ .addExtras(Bundle().apply { putString(DATA_EXTRA, content.data.toString()) })
43
+ .build()
44
+ manager.notify(content.id, NOTIFICATION_ID, notification)
45
+ }
46
+
47
+ /** The app's shown notifications, identified by tag, as the page sees them. */
48
+ fun delivered(): List<JSONObject> =
49
+ manager.activeNotifications.mapNotNull { shown ->
50
+ val id = shown.tag?.takeIf { shown.id == NOTIFICATION_ID } ?: return@mapNotNull null
51
+ val extras = shown.notification.extras
52
+ JSONObject()
53
+ .put("id", id)
54
+ .put("title", extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: "")
55
+ .put("data", JSONObject(extras.getString(DATA_EXTRA) ?: "{}"))
56
+ .apply {
57
+ extras.getCharSequence(Notification.EXTRA_TEXT)?.let { put("body", it.toString()) }
58
+ }
59
+ }
60
+
61
+ fun remove(id: String) = manager.cancel(id, NOTIFICATION_ID)
62
+
63
+ /** The app's monochrome `drawable/tokamak_notification`, or its launcher icon. */
64
+ private fun icon(): Int =
65
+ context.resources
66
+ .getIdentifier("tokamak_notification", "drawable", context.packageName)
67
+ .takeIf { it != 0 }
68
+ ?: context.applicationInfo.icon.takeIf { it != 0 }
69
+ ?: android.R.drawable.sym_def_app_icon
70
+
71
+ private fun launch(id: String, opened: JSONObject): PendingIntent {
72
+ val intent =
73
+ requireNotNull(context.packageManager.getLaunchIntentForPackage(context.packageName))
74
+ .setIdentifier(id)
75
+ .putExtra(OPENED_EXTRA, opened.toString())
76
+ return PendingIntent.getActivity(
77
+ context,
78
+ 0,
79
+ intent,
80
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
81
+ )
82
+ }
83
+
84
+ private fun opened(content: Content, source: String): JSONObject =
85
+ JSONObject()
86
+ .put("id", content.id)
87
+ .put("title", content.title)
88
+ .put("body", content.body ?: JSONObject.NULL)
89
+ .put("data", content.data)
90
+ .put("source", source)
91
+ }