@craft-native/android 0.0.451 → 0.0.467

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.
@@ -15,30 +15,228 @@ import org.json.JSONObject
15
15
  /** Delivers native-runtime notifications even when the app process has exited. */
16
16
  internal class CraftNativeNotificationReceiver : BroadcastReceiver() {
17
17
  override fun onReceive(context: Context, intent: Intent) {
18
+ intent.getStringExtra("craft.dismissedId")?.let {
19
+ CraftNativeNotifications.cancel(context, it, intent.getStringExtra("craft.dismissedThread"))
20
+ return
21
+ }
22
+ if (intent.hasExtra("craft.actionToken")) {
23
+ val pending = goAsync()
24
+ CraftNativeNotifications.receiveAction(context, intent) { pending.finish() }
25
+ return
26
+ }
18
27
  val id = intent.getStringExtra("id").orEmpty()
19
28
  if (id.isEmpty()) return
20
29
  val storage = context.getSharedPreferences("craft-native-notifications", Context.MODE_PRIVATE)
21
30
  val value = storage.getString(id, null)?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return
22
31
  storage.edit().remove(id).commit()
32
+ CraftNativeNotifications.show(context, value, id, "craft_notifications")
33
+ }
34
+ }
35
+
36
+ /** Shared by scheduled locals and app-rendered FCM data messages. */
37
+ internal object CraftNativeNotifications {
38
+ private const val REPLY = "craft.reply"
39
+
40
+ private fun config(context: Context): JSONObject = runCatching {
41
+ context.assets.open("craft.config.json").bufferedReader().use { JSONObject(it.readText()) }
42
+ }.getOrDefault(JSONObject())
43
+
44
+ internal fun actions(config: JSONObject, categoryId: String): List<JSONObject> {
45
+ val categories = config.optJSONArray("notificationCategories") ?: return emptyList()
46
+ for (index in 0 until categories.length()) {
47
+ val category = categories.optJSONObject(index) ?: continue
48
+ if (category.optString("id") != categoryId) continue
49
+ val entries = category.optJSONArray("actions") ?: return emptyList()
50
+ return (0 until entries.length()).mapNotNull { entries.optJSONObject(it) }
51
+ }
52
+ return emptyList()
53
+ }
54
+
55
+ internal fun actionDetail(config: JSONObject, value: JSONObject, actionId: String, reply: CharSequence?): JSONObject? {
56
+ val categoryId = value.optString("categoryId")
57
+ val action = actions(config, categoryId).firstOrNull { it.optString("id") == actionId } ?: return null
58
+ val detail = JSONObject().put("actionId", actionId).put("notificationId", value.optString("id"))
59
+ .put("categoryId", categoryId).put("data", value.optJSONObject("data") ?: JSONObject())
60
+ if (reply != null && action.optJSONObject("textInput") != null) detail.put("userText", reply.toString())
61
+ return detail
62
+ }
63
+
64
+ internal fun invalidOptions(value: JSONObject): String? {
65
+ for (key in listOf("categoryId", "threadIdentifier")) {
66
+ if (value.has(key) && value.opt(key) !is String) return "$key must be a string"
67
+ }
68
+ if (value.has("interruptionLevel") && value.opt("interruptionLevel") !in listOf("passive", "active", "timeSensitive")) {
69
+ return "interruptionLevel must be passive, active or timeSensitive"
70
+ }
71
+ return null
72
+ }
73
+
74
+ internal fun consumeAction(storage: android.content.SharedPreferences, token: String): JSONObject? {
75
+ val saved = storage.getString(token, null)?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
76
+ if (!storage.edit().remove(token).commit()) return null
77
+ return saved
78
+ }
79
+
80
+ @Synchronized
81
+ fun receiveAction(context: Context, intent: Intent?, queued: (() -> Unit)? = null): Boolean {
82
+ val token = intent?.getStringExtra("craft.actionToken") ?: return false.also { queued?.invoke() }
83
+ val storage = context.getSharedPreferences("craft-native-notification-actions", Context.MODE_PRIVATE)
84
+ // MainActivity is exported. Never turn arbitrary launch extras into
85
+ // an authenticated notification action: only a token we minted can
86
+ // name an action/payload. RemoteInput supplies just the reply text.
87
+ val saved = consumeAction(storage, token)
88
+ if (saved == null) { queued?.invoke(); return true }
89
+ intent.removeExtra("craft.actionToken")
90
+ val value = saved.optJSONObject("value") ?: return true.also { queued?.invoke() }
91
+ val actionId = saved.optString("actionId")
92
+ val detail = actionDetail(config(context), value, actionId,
93
+ androidx.core.app.RemoteInput.getResultsFromIntent(intent)?.getCharSequence(REPLY))
94
+ if (detail == null) { queued?.invoke(); return true }
95
+ cancel(context, detail.optString("notificationId"))
96
+ CraftNativeBackground.runOnce(context, "craftNotificationAction", detail, queued)
97
+ return true
98
+ }
99
+
100
+ @Synchronized
101
+ fun revoke(context: Context, notificationId: String? = null) {
102
+ val storage = context.getSharedPreferences("craft-native-notification-actions", Context.MODE_PRIVATE)
103
+ if (notificationId == null) { storage.edit().clear().commit(); return }
104
+ val tokens = storage.all.filter { (key, raw) ->
105
+ !key.startsWith("index:") && runCatching { JSONObject(raw as String).optJSONObject("value")?.optString("id") == notificationId }.getOrDefault(false)
106
+ }.keys
107
+ val edit = storage.edit()
108
+ storage.all.forEach { (key, value) -> if (key in tokens || (key.startsWith("index:") && value is String && value in tokens)) edit.remove(key) }
109
+ edit.commit()
110
+ }
111
+
112
+ @Synchronized
113
+ fun clearEmptyGroup(context: Context, thread: String?) {
114
+ if (thread.isNullOrEmpty()) return
115
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
116
+ val children = notificationGroups(context).all.any { (key, value) ->
117
+ key.startsWith("notification:") && value == thread
118
+ }
119
+ if (!children) manager.cancel("craft.group:$thread", 1)
120
+ }
121
+
122
+ @Synchronized
123
+ fun cancel(context: Context, id: String, dismissedThread: String? = null) {
124
+ revoke(context, id)
125
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
126
+ val groups = notificationGroups(context)
127
+ val thread = groups.getString("notification:$id", null) ?: dismissedThread
128
+ check(groups.edit().remove("notification:$id").commit()) { "Could not save notification cancellation" }
129
+ manager.cancel(id, 0)
130
+ clearEmptyGroup(context, thread)
131
+ }
132
+
133
+ @Synchronized
134
+ fun cancelAll(context: Context) {
135
+ revoke(context)
136
+ check(notificationGroups(context).edit().clear().putBoolean("initialized", true).commit()) { "Could not clear notification groups" }
137
+ (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancelAll()
138
+ }
139
+
140
+ private fun notificationGroups(context: Context): android.content.SharedPreferences {
141
+ val storage = context.getSharedPreferences("craft-native-notification-groups", Context.MODE_PRIVATE)
142
+ if (!storage.getBoolean("initialized", false)) {
143
+ // Adopt notifications posted by earlier app versions once. After
144
+ // migration, our durable intent owns membership: OS snapshots can
145
+ // lag both notify() and cancel(), including across receiver calls.
146
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
147
+ val edit = storage.edit().putBoolean("initialized", true)
148
+ manager.activeNotifications.filter {
149
+ it.tag != null && it.id == 0 && (it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
150
+ }.forEach { entry ->
151
+ entry.notification.group?.takeIf { it.isNotEmpty() }?.let { edit.putString("notification:${entry.tag}", it) }
152
+ }
153
+ check(edit.commit()) { "Could not initialize notification groups" }
154
+ }
155
+ return storage
156
+ }
157
+
158
+ private fun pending(context: Context, value: JSONObject, action: JSONObject?): android.app.PendingIntent? {
159
+ val foreground = action == null || action.optBoolean("foreground", false)
160
+ val target = if (foreground) Intent(context, MainActivity::class.java)
161
+ .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
162
+ else Intent(context, CraftNativeNotificationReceiver::class.java)
163
+ target.action = "craft.notification:" + android.net.Uri.encode(value.optString("id")) + ":" +
164
+ (if (action == null) "tap" else "action:" + android.net.Uri.encode(action.optString("id")))
165
+ target.putExtra("craft.notification", true)
166
+ if (action == null) target.putExtra("craft.notification.data", (value.optJSONObject("data") ?: JSONObject()).toString())
167
+ else {
168
+ val token = java.util.UUID.randomUUID().toString()
169
+ val storage = context.getSharedPreferences("craft-native-notification-actions", Context.MODE_PRIVATE)
170
+ val index = "index:" + target.action
171
+ val edit = storage.edit()
172
+ storage.getString(index, null)?.let { edit.remove(it) }
173
+ if (!edit.putString(index, token).putString(token, JSONObject().put("actionId", action.optString("id"))
174
+ .put("value", value).toString()).commit()) return null
175
+ target.putExtra("craft.actionToken", token)
176
+ }
177
+ val mutable = action?.optJSONObject("textInput") != null
178
+ val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
179
+ (if (mutable) android.app.PendingIntent.FLAG_MUTABLE else android.app.PendingIntent.FLAG_IMMUTABLE)
180
+ return if (foreground) android.app.PendingIntent.getActivity(context, 0, target, flags)
181
+ else android.app.PendingIntent.getBroadcast(context, 0, target, flags)
182
+ }
183
+
184
+ @Synchronized
185
+ fun show(context: Context, original: JSONObject, id: String, channel: String) {
186
+ if (invalidOptions(original) != null) return
23
187
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
24
188
  ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
25
-
189
+ revoke(context, id) // replacing a notification also invalidates removed actions
190
+ val value = JSONObject(original.toString()).put("id", id)
26
191
  val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
27
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
28
- manager.createNotificationChannel(NotificationChannel(
29
- "craft_notifications",
30
- "Craft Notifications",
31
- NotificationManager.IMPORTANCE_DEFAULT,
32
- ))
33
- }
34
- val notification = NotificationCompat.Builder(context, "craft_notifications")
192
+ val level = value.optString("interruptionLevel", "active")
193
+ val channelId = if (level == "active") channel else "$channel.$level"
194
+ val importance = when (level) {
195
+ "passive" -> NotificationManager.IMPORTANCE_LOW
196
+ "timeSensitive" -> NotificationManager.IMPORTANCE_HIGH
197
+ else -> NotificationManager.IMPORTANCE_DEFAULT
198
+ }
199
+ manager.createNotificationChannel(NotificationChannel(channelId, "${context.applicationInfo.loadLabel(context.packageManager)} ($level)", importance))
200
+ val notification = NotificationCompat.Builder(context, channelId)
35
201
  .setSmallIcon(R.drawable.craft_notification_icon)
36
202
  .setColor(ContextCompat.getColor(context, R.color.craft_notification_color))
37
- .setContentTitle(value.optString("title"))
38
- .setContentText(value.optString("body"))
39
- .setPriority(NotificationCompat.PRIORITY_DEFAULT)
40
- .setAutoCancel(true)
41
- .build()
42
- manager.notify(id, 0, notification)
203
+ .setContentTitle(value.optString("title")).setContentText(value.optString("body"))
204
+ .setStyle(NotificationCompat.BigTextStyle().bigText(value.optString("body")))
205
+ .setContentIntent(pending(context, value, null)).setAutoCancel(true)
206
+ val thread = value.optString("threadIdentifier")
207
+ if (thread.isNotEmpty()) notification.setGroup(thread)
208
+ actions(config(context), value.optString("categoryId")).filter {
209
+ // API <31 cannot enforce action authentication. Omit the action
210
+ // instead of silently weakening the app's request.
211
+ !it.optBoolean("authenticationRequired", false) || Build.VERSION.SDK_INT >= 31
212
+ }.forEach { item ->
213
+ val target = pending(context, value, item) ?: return@forEach
214
+ val action = NotificationCompat.Action.Builder(0, item.optString("title"), target)
215
+ .setAuthenticationRequired(item.optBoolean("authenticationRequired", false))
216
+ if (item.optBoolean("destructive", false)) action.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_DELETE)
217
+ item.optJSONObject("textInput")?.let { input ->
218
+ action.addRemoteInput(androidx.core.app.RemoteInput.Builder(REPLY)
219
+ .setLabel(input.optString("placeholder", item.optString("title"))).build())
220
+ action.setAllowGeneratedReplies(true)
221
+ }
222
+ notification.addAction(action.build())
223
+ }
224
+ val dismissed = Intent(context, CraftNativeNotificationReceiver::class.java)
225
+ .setAction("craft.notification.dismiss:" + android.net.Uri.encode(id)).putExtra("craft.dismissedId", id).putExtra("craft.dismissedThread", thread)
226
+ notification.setDeleteIntent(android.app.PendingIntent.getBroadcast(context, 0, dismissed,
227
+ android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE))
228
+ val groups = notificationGroups(context)
229
+ val previousThread = groups.getString("notification:$id", null)
230
+ val edit = groups.edit()
231
+ if (thread.isEmpty()) edit.remove("notification:$id") else edit.putString("notification:$id", thread)
232
+ check(edit.commit()) { "Could not save notification group" }
233
+ manager.notify(id, 0, notification.build())
234
+ if (thread.isNotEmpty()) {
235
+ manager.notify("craft.group:$thread", 1, NotificationCompat.Builder(context, channelId)
236
+ .setSmallIcon(R.drawable.craft_notification_icon)
237
+ .setContentTitle(context.applicationInfo.loadLabel(context.packageManager))
238
+ .setContentIntent(pending(context, value, null)).setGroup(thread).setGroupSummary(true).setAutoCancel(true).build())
239
+ }
240
+ if (previousThread != thread) clearEmptyGroup(context, previousThread)
43
241
  }
44
242
  }
@@ -119,6 +119,8 @@ internal class CraftNativeServices(
119
119
  if (config.optBoolean("enableAudioRecording")) add("audioRecording")
120
120
  // Videos kept on the phone for offline (CraftNativeMedia).
121
121
  add("media")
122
+ add("media-cache-key")
123
+ add("media-remove")
122
124
  if (config.optBoolean("enableShare")) add("share")
123
125
  if (config.optBoolean("enablePushNotifications")) add("push")
124
126
  if (health != null || healthServices != null) {
@@ -139,7 +141,8 @@ internal class CraftNativeServices(
139
141
  if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
140
142
  if (CraftNative.supportsRecordingPowerMode()) add("locationRecordingPowerMode")
141
143
  }
142
- if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")) add("background")
144
+ if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications") ||
145
+ (config.optJSONArray("notificationCategories")?.length() ?: 0) > 0) add("background")
143
146
  // A Bluetooth heart-rate strap (CraftHeartRate.kt), the app's own
144
147
  // service: one connection and one recording for every screen.
145
148
  if (heartRateEnabled) addAll(listOf("heartRate", "heartRateRecordingOwnership", "heartRateRecordingIntentIdentity"))
@@ -179,14 +182,29 @@ internal class CraftNativeServices(
179
182
  "Location" -> location(method, args, done)
180
183
  "HeartRate" -> heartRate(method, args, done)
181
184
  "Media" -> when (method) {
182
- "keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), args.optJSONObject(1)?.optBoolean("cellular", true) ?: true, done)
183
- "path" -> done(success(CraftNativeMedia.local(context, args.optString(0))?.absolutePath ?: JSONObject.NULL))
185
+ "keep" -> CraftNativeMedia.keep(context, args.optString(0), args.optJSONObject(1)?.optJSONObject("headers") ?: JSONObject(), args.optJSONObject(1)?.optBoolean("cellular", true) ?: true, cacheKey = args.optJSONObject(1)?.optString("cacheKey"), done = done)
186
+ "path" -> done(success(CraftNativeMedia.local(context, CraftNativeMedia.identity(args.optString(0), args.optJSONObject(1)))?.absolutePath ?: JSONObject.NULL))
184
187
  "prune" -> {
185
188
  val keep = args.optJSONArray(0) ?: JSONArray()
186
- done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { keep.optString(it) })))
189
+ done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { index -> keep.optJSONObject(index)?.let { CraftNativeMedia.identity(it.optString("url"), it) } ?: keep.optString(index) })))
187
190
  }
188
191
  "usage" -> done(success(CraftNativeMedia.usage(context)))
189
- "clear" -> done(success(CraftNativeMedia.clear(context)))
192
+ "clear" -> {
193
+ try { done(success(CraftNativeMedia.clear(context))) }
194
+ catch (error: java.io.IOException) { done(failure("IO_ERROR", error.message ?: "The media cache could not be cleared")) }
195
+ }
196
+ "remove" -> {
197
+ val options = args.optJSONObject(1)
198
+ val key = options?.opt("cacheKey")
199
+ if (key != null && key != JSONObject.NULL && (key !is String || key.isEmpty() || key.toByteArray().size > 1024)) {
200
+ done(failure("INVALID_ARGUMENT", "media.remove cacheKey must be a nonempty string of at most 1024 bytes"))
201
+ } else {
202
+ val address = CraftNativeMedia.identity(args.optString(0), options)
203
+ if (address.isBlank()) done(failure("INVALID_ARGUMENT", "media.remove needs a source or cache key"))
204
+ else try { done(success(CraftNativeMedia.remove(context, address))) }
205
+ catch (error: java.io.IOException) { done(failure("IO_ERROR", error.message ?: "The media copy could not be removed")) }
206
+ }
207
+ }
190
208
  else -> done(failure("UNKNOWN_ACTION", "Unsupported Media method"))
191
209
  }
192
210
  "Background" -> {