@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.
@@ -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
+ }
@@ -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
+ }