@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/android/Push.kt
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
package com.tokamak.plugins.notifications
|
|
2
|
+
|
|
3
|
+
import android.content.ContentProvider
|
|
4
|
+
import android.content.ContentValues
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.database.Cursor
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import com.google.firebase.FirebaseApp
|
|
9
|
+
import com.google.firebase.FirebaseOptions
|
|
10
|
+
import com.google.firebase.messaging.FirebaseMessagingService
|
|
11
|
+
import com.google.firebase.messaging.RemoteMessage
|
|
12
|
+
import com.tokamak.runtime.tokamakHost
|
|
13
|
+
import java.util.UUID
|
|
14
|
+
import org.json.JSONObject
|
|
15
|
+
|
|
16
|
+
private const val APPLICATION_ID = "com.tokamak.notifications.fcm.application-id"
|
|
17
|
+
private const val PROJECT_ID = "com.tokamak.notifications.fcm.project-id"
|
|
18
|
+
private const val API_KEY = "com.tokamak.notifications.fcm.api-key"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Starts Firebase from the Firebase project values in the app's manifest when the process
|
|
22
|
+
* starts, before FCM delivers anything. FCM takes the sender ID from the application ID.
|
|
23
|
+
*/
|
|
24
|
+
class TokamakFirebaseInitializer : ContentProvider() {
|
|
25
|
+
override fun onCreate(): Boolean {
|
|
26
|
+
val context = context ?: return false
|
|
27
|
+
val metadata =
|
|
28
|
+
context.packageManager
|
|
29
|
+
.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA)
|
|
30
|
+
.metaData ?: return true
|
|
31
|
+
val applicationId = metadata.getString(APPLICATION_ID)
|
|
32
|
+
val projectId = metadata.getString(PROJECT_ID)
|
|
33
|
+
val apiKey = metadata.getString(API_KEY)
|
|
34
|
+
if (applicationId == null || projectId == null || apiKey == null) return true
|
|
35
|
+
val options =
|
|
36
|
+
FirebaseOptions.Builder()
|
|
37
|
+
.setApplicationId(applicationId)
|
|
38
|
+
.setProjectId(projectId)
|
|
39
|
+
.setApiKey(apiKey)
|
|
40
|
+
.build()
|
|
41
|
+
FirebaseApp.initializeApp(context, options)
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override fun query(
|
|
46
|
+
uri: Uri,
|
|
47
|
+
projection: Array<out String>?,
|
|
48
|
+
selection: String?,
|
|
49
|
+
selectionArgs: Array<out String>?,
|
|
50
|
+
sortOrder: String?,
|
|
51
|
+
): Cursor? = null
|
|
52
|
+
|
|
53
|
+
override fun getType(uri: Uri): String? = null
|
|
54
|
+
|
|
55
|
+
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
|
56
|
+
|
|
57
|
+
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
|
58
|
+
|
|
59
|
+
override fun update(
|
|
60
|
+
uri: Uri,
|
|
61
|
+
values: ContentValues?,
|
|
62
|
+
selection: String?,
|
|
63
|
+
selectionArgs: Array<out String>?,
|
|
64
|
+
): Int = 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Receives FCM tokens and messages, including when FCM starts the app to deliver one. */
|
|
68
|
+
class TokamakPushService : FirebaseMessagingService() {
|
|
69
|
+
private val plugin: TokamakNotificationsPlugin?
|
|
70
|
+
get() = tokamakHost.plugin("notifications") as? TokamakNotificationsPlugin
|
|
71
|
+
|
|
72
|
+
override fun onNewToken(token: String) {
|
|
73
|
+
plugin?.onNewToken(token)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override fun onMessageReceived(message: RemoteMessage) {
|
|
77
|
+
plugin?.onMessageReceived(message(message), message.notification != null)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The page's view of an FCM message: its data is the message's data map. */
|
|
81
|
+
private fun message(message: RemoteMessage): JSONObject =
|
|
82
|
+
JSONObject()
|
|
83
|
+
.put("id", message.messageId ?: UUID.randomUUID().toString())
|
|
84
|
+
.put("title", message.notification?.title ?: JSONObject.NULL)
|
|
85
|
+
.put("body", message.notification?.body ?: JSONObject.NULL)
|
|
86
|
+
.put("data", JSONObject(message.data as Map<*, *>))
|
|
87
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package com.tokamak.plugins.notifications
|
|
2
|
+
|
|
3
|
+
import android.app.AlarmManager
|
|
4
|
+
import android.app.PendingIntent
|
|
5
|
+
import android.content.BroadcastReceiver
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.content.Intent
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
private const val PREFERENCES = "tokamak.notifications.schedule"
|
|
11
|
+
private const val ID_EXTRA = "com.tokamak.notifications.scheduled"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Scheduled notifications, stored so they survive restarts, each with an inexact alarm
|
|
15
|
+
* allowed while idle. Use it on the main thread.
|
|
16
|
+
*/
|
|
17
|
+
internal class Schedule(private val context: Context) {
|
|
18
|
+
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
19
|
+
private val alarms = context.getSystemService(AlarmManager::class.java)
|
|
20
|
+
|
|
21
|
+
fun all(): List<Content> =
|
|
22
|
+
preferences.all.values.map { Content.fromJson(JSONObject(it as String)) }
|
|
23
|
+
|
|
24
|
+
fun add(content: Content) {
|
|
25
|
+
preferences.edit().putString(content.id, content.toJson().toString()).apply()
|
|
26
|
+
arm(content)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fun remove(id: String) {
|
|
30
|
+
preferences.edit().remove(id).apply()
|
|
31
|
+
alarms.cancel(alarm(id))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Removes and returns the notification scheduled as [id]. */
|
|
35
|
+
fun take(id: String): Content? {
|
|
36
|
+
val stored = preferences.getString(id, null) ?: return null
|
|
37
|
+
preferences.edit().remove(id).apply()
|
|
38
|
+
return Content.fromJson(JSONObject(stored))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Sets an alarm for every stored notification, as a restart clears them. */
|
|
42
|
+
fun armAll() = all().forEach(::arm)
|
|
43
|
+
|
|
44
|
+
private fun arm(content: Content) {
|
|
45
|
+
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, content.at ?: 0, alarm(content.id))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private fun alarm(id: String): PendingIntent =
|
|
49
|
+
PendingIntent.getBroadcast(
|
|
50
|
+
context,
|
|
51
|
+
0,
|
|
52
|
+
Intent(context, TokamakAlarmReceiver::class.java).setIdentifier(id).putExtra(ID_EXTRA, id),
|
|
53
|
+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
companion object {
|
|
57
|
+
fun scheduledId(intent: Intent): String? = intent.getStringExtra(ID_EXTRA)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Shows a scheduled notification when its alarm fires. */
|
|
62
|
+
class TokamakAlarmReceiver : BroadcastReceiver() {
|
|
63
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
64
|
+
val id = Schedule.scheduledId(intent) ?: return
|
|
65
|
+
val content = Schedule(context).take(id) ?: return
|
|
66
|
+
Notifier(context).post(content, "local")
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Restores the schedule's alarms after the device restarts or the app updates. */
|
|
71
|
+
class TokamakBootReceiver : BroadcastReceiver() {
|
|
72
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
73
|
+
if (intent.action == Intent.ACTION_BOOT_COMPLETED || intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
|
74
|
+
Schedule(context).armAll()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
package/apple/Info.plist
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>UIBackgroundModes</key>
|
|
6
|
+
<array>
|
|
7
|
+
<string>remote-notification</string>
|
|
8
|
+
</array>
|
|
9
|
+
</dict>
|
|
10
|
+
</plist>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UserNotifications
|
|
3
|
+
|
|
4
|
+
/// Keys the plugin stores in a local notification's `userInfo`.
|
|
5
|
+
private let dataKey = "tokamak.data"
|
|
6
|
+
private let atKey = "tokamak.at"
|
|
7
|
+
|
|
8
|
+
/// A notification the page asked to show or schedule.
|
|
9
|
+
struct TokamakNotificationRequest {
|
|
10
|
+
let id: String
|
|
11
|
+
let title: String
|
|
12
|
+
let body: String?
|
|
13
|
+
let data: [String: Any]
|
|
14
|
+
/// Milliseconds since the epoch; nil to show now.
|
|
15
|
+
let at: Double?
|
|
16
|
+
|
|
17
|
+
init(_ arguments: Any, scheduled: Bool) throws(TokamakPluginError) {
|
|
18
|
+
let arguments = arguments as? [String: Any] ?? [:]
|
|
19
|
+
guard let id = arguments["id"] as? String, !id.isEmpty else {
|
|
20
|
+
throw typeError("id must be a non-empty string")
|
|
21
|
+
}
|
|
22
|
+
guard let title = arguments["title"] as? String else {
|
|
23
|
+
throw typeError("title must be a string")
|
|
24
|
+
}
|
|
25
|
+
self.id = id
|
|
26
|
+
self.title = title
|
|
27
|
+
body = arguments["body"] as? String
|
|
28
|
+
data = arguments["data"] as? [String: Any] ?? [:]
|
|
29
|
+
guard scheduled else {
|
|
30
|
+
at = nil
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
guard let at = (arguments["at"] as? NSNumber)?.doubleValue, at.isFinite else {
|
|
34
|
+
throw typeError("at must be a number of milliseconds since the epoch")
|
|
35
|
+
}
|
|
36
|
+
self.at = at
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The system request, due now when `at` is absent or has passed.
|
|
40
|
+
func request() throws(TokamakPluginError) -> UNNotificationRequest {
|
|
41
|
+
let content = UNMutableNotificationContent()
|
|
42
|
+
content.title = title
|
|
43
|
+
content.body = body ?? ""
|
|
44
|
+
content.sound = .default
|
|
45
|
+
var userInfo: [String: Any] = [dataKey: try json(data)]
|
|
46
|
+
var trigger: UNNotificationTrigger?
|
|
47
|
+
if let at, at > Date().timeIntervalSince1970 * 1000 {
|
|
48
|
+
userInfo[atKey] = at
|
|
49
|
+
trigger = UNCalendarNotificationTrigger(dateMatching: components(at), repeats: false)
|
|
50
|
+
}
|
|
51
|
+
content.userInfo = userInfo
|
|
52
|
+
return UNNotificationRequest(identifier: id, content: content, trigger: trigger)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private func components(_ at: Double) -> DateComponents {
|
|
56
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
57
|
+
calendar.timeZone = TimeZone(identifier: "UTC")!
|
|
58
|
+
return calendar.dateComponents(
|
|
59
|
+
[.calendar, .timeZone, .year, .month, .day, .hour, .minute, .second],
|
|
60
|
+
from: Date(timeIntervalSince1970: at / 1000)
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// The page's view of a notification: its content, and whether it came from a
|
|
66
|
+
/// push message.
|
|
67
|
+
struct TokamakNotificationFields {
|
|
68
|
+
let id: String
|
|
69
|
+
let title: String?
|
|
70
|
+
let body: String?
|
|
71
|
+
let data: [String: Any]
|
|
72
|
+
let isPush: Bool
|
|
73
|
+
|
|
74
|
+
init(_ notification: UNNotification) {
|
|
75
|
+
let content = notification.request.content
|
|
76
|
+
id = notification.request.identifier
|
|
77
|
+
title = content.title.isEmpty ? nil : content.title
|
|
78
|
+
body = content.body.isEmpty ? nil : content.body
|
|
79
|
+
(data, isPush) = Self.data(content.userInfo)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// A push message delivered to the app without a notification.
|
|
83
|
+
init(remoteNotification userInfo: [AnyHashable: Any]) {
|
|
84
|
+
let alert = (userInfo["aps"] as? [String: Any])?["alert"]
|
|
85
|
+
id = UUID().uuidString
|
|
86
|
+
title = (alert as? [String: Any])?["title"] as? String
|
|
87
|
+
body = (alert as? [String: Any])?["body"] as? String ?? alert as? String
|
|
88
|
+
(data, isPush) = Self.data(userInfo)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// A push message's data is every top-level key but `aps`; a local
|
|
92
|
+
/// notification's is the data the page gave it.
|
|
93
|
+
static func data(_ userInfo: [AnyHashable: Any]) -> ([String: Any], isPush: Bool) {
|
|
94
|
+
if userInfo["aps"] != nil {
|
|
95
|
+
var data: [String: Any] = [:]
|
|
96
|
+
for case (let key as String, let value) in userInfo where key != "aps" {
|
|
97
|
+
data[key] = value
|
|
98
|
+
}
|
|
99
|
+
return (data, true)
|
|
100
|
+
}
|
|
101
|
+
let json = (userInfo[dataKey] as? String)?.data(using: .utf8) ?? Data()
|
|
102
|
+
return ((try? JSONSerialization.jsonObject(with: json)) as? [String: Any] ?? [:], false)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
var message: [String: Any] {
|
|
106
|
+
["id": id, "title": title ?? NSNull(), "body": body ?? NSNull(), "data": data]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
var opened: [String: Any] {
|
|
110
|
+
message.merging(["source": isPush ? "push" : "local"]) { current, _ in current }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
var content: [String: Any] {
|
|
114
|
+
var content: [String: Any] = ["id": id, "title": title ?? "", "data": data]
|
|
115
|
+
content["body"] = body
|
|
116
|
+
return content
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
extension UNNotificationRequest {
|
|
121
|
+
/// The page's view of a pending request.
|
|
122
|
+
var scheduled: [String: Any] {
|
|
123
|
+
let at =
|
|
124
|
+
content.userInfo[atKey] as? Double
|
|
125
|
+
?? (trigger as? UNCalendarNotificationTrigger)?.nextTriggerDate().map {
|
|
126
|
+
$0.timeIntervalSince1970 * 1000
|
|
127
|
+
} ?? 0
|
|
128
|
+
var scheduled: [String: Any] = [
|
|
129
|
+
"id": identifier,
|
|
130
|
+
"title": content.title,
|
|
131
|
+
"data": TokamakNotificationFields.data(content.userInfo).0,
|
|
132
|
+
"at": at,
|
|
133
|
+
]
|
|
134
|
+
scheduled["body"] = content.body.isEmpty ? nil : content.body
|
|
135
|
+
return scheduled
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func json(_ value: [String: Any]) throws(TokamakPluginError) -> String {
|
|
140
|
+
guard
|
|
141
|
+
JSONSerialization.isValidJSONObject(value),
|
|
142
|
+
let data = try? JSONSerialization.data(withJSONObject: value)
|
|
143
|
+
else {
|
|
144
|
+
throw typeError("data must be a JSON object")
|
|
145
|
+
}
|
|
146
|
+
return String(decoding: data, as: UTF8.self)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private func typeError(_ message: String) -> TokamakPluginError {
|
|
150
|
+
TokamakPluginError(name: "TypeError", message: message)
|
|
151
|
+
}
|