@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.
- package/README.md +19 -1
- package/dist/index.d.ts +1 -350
- package/dist/index.js +228 -31
- package/dist/index.js.map +7 -5
- package/dist/internal/android/src/index.d.ts +358 -0
- package/dist/internal/android/src/testing.d.ts +2 -0
- package/dist/internal/typescript/src/native-notifications.d.ts +17 -0
- package/dist/internal/typescript/src/native-routing.d.ts +26 -0
- package/package.json +1 -1
- package/templates/CraftNativeBackground.kt.template +65 -25
- package/templates/CraftNativeCapabilities.kt.template +3 -1
- package/templates/CraftNativeMessagingService.kt.template +8 -28
- package/templates/CraftNativeNotificationReceiver.kt.template +213 -15
- package/templates/CraftNativeServices.kt.template +23 -5
- package/templates/CraftNativeShell.kt.template +201 -50
- package/templates/MainActivityNative.kt.template +227 -12
- package/dist/testing.d.ts +0 -2
- /package/dist/{emulator.d.ts → internal/android/src/emulator.d.ts} +0 -0
- /package/dist/{native-map.d.ts → internal/android/src/native-map.d.ts} +0 -0
- /package/dist/{promise-runtime.d.ts → internal/android/src/promise-runtime.d.ts} +0 -0
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
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
|
-
.
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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")
|
|
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" ->
|
|
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" -> {
|