@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 +206 -0
- package/android/AndroidManifest.xml +30 -0
- package/android/Content.kt +47 -0
- package/android/NotificationsPlugin.kt +287 -0
- package/android/Notifier.kt +91 -0
- package/android/Push.kt +87 -0
- package/android/Schedule.kt +77 -0
- package/apple/Info.plist +10 -0
- package/apple/NotificationContent.swift +151 -0
- package/apple/NotificationsPlugin.swift +381 -0
- package/package.json +29 -0
- package/service-worker/index.ts +142 -0
- package/src/index.ts +145 -0
- package/tokamak-plugin.json +39 -0
- package/web/index.ts +180 -0
- package/web/protocol.ts +43 -0
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
|
+
}
|