@craft-native/android 0.0.451 → 0.0.464
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 +6 -4
- package/templates/CraftNativeShell.kt.template +72 -21
- 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,7 @@ 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")
|
|
122
123
|
if (config.optBoolean("enableShare")) add("share")
|
|
123
124
|
if (config.optBoolean("enablePushNotifications")) add("push")
|
|
124
125
|
if (health != null || healthServices != null) {
|
|
@@ -139,7 +140,8 @@ internal class CraftNativeServices(
|
|
|
139
140
|
if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
|
|
140
141
|
if (CraftNative.supportsRecordingPowerMode()) add("locationRecordingPowerMode")
|
|
141
142
|
}
|
|
142
|
-
if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")
|
|
143
|
+
if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications") ||
|
|
144
|
+
(config.optJSONArray("notificationCategories")?.length() ?: 0) > 0) add("background")
|
|
143
145
|
// A Bluetooth heart-rate strap (CraftHeartRate.kt), the app's own
|
|
144
146
|
// service: one connection and one recording for every screen.
|
|
145
147
|
if (heartRateEnabled) addAll(listOf("heartRate", "heartRateRecordingOwnership", "heartRateRecordingIntentIdentity"))
|
|
@@ -179,11 +181,11 @@ internal class CraftNativeServices(
|
|
|
179
181
|
"Location" -> location(method, args, done)
|
|
180
182
|
"HeartRate" -> heartRate(method, args, done)
|
|
181
183
|
"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))
|
|
184
|
+
"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)
|
|
185
|
+
"path" -> done(success(CraftNativeMedia.local(context, CraftNativeMedia.identity(args.optString(0), args.optJSONObject(1)))?.absolutePath ?: JSONObject.NULL))
|
|
184
186
|
"prune" -> {
|
|
185
187
|
val keep = args.optJSONArray(0) ?: JSONArray()
|
|
186
|
-
done(success(CraftNativeMedia.prune(context, (0 until keep.length()).map { keep.optString(it) })))
|
|
188
|
+
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
189
|
}
|
|
188
190
|
"usage" -> done(success(CraftNativeMedia.usage(context)))
|
|
189
191
|
"clear" -> done(success(CraftNativeMedia.clear(context)))
|
|
@@ -98,18 +98,21 @@ internal data class CraftNativeChromeFonts(val body: Typeface? = null, val headi
|
|
|
98
98
|
|
|
99
99
|
/** The app's paths (`nativeScreens`), its tabs and its site. */
|
|
100
100
|
internal class CraftNativeRoutes(config: JSONObject) {
|
|
101
|
-
private data class Route(val segments: List<String>, val screen: String)
|
|
101
|
+
private data class Route(val segments: List<String>, val screen: String, val constraints: Any?)
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* The iOS shell's order (CraftHybrid.swift's route table): a trailing
|
|
105
105
|
* `*` last, then more fixed segments first, then longer patterns, then
|
|
106
106
|
* by name, so `/m/store/library` is never taken for `/m/store/:slug`.
|
|
107
107
|
*/
|
|
108
|
+
private val validConstraints = !config.has("nativeScreenConstraints") || config.opt("nativeScreenConstraints") is JSONObject
|
|
109
|
+
|
|
108
110
|
private val routes: List<Route> = buildList {
|
|
109
111
|
val screens = config.optJSONObject("nativeScreens") ?: JSONObject()
|
|
112
|
+
val constraints = config.optJSONObject("nativeScreenConstraints")
|
|
110
113
|
screens.keys().forEach { pattern ->
|
|
111
114
|
val screen = screens.optString(pattern)
|
|
112
|
-
if (screen.isNotBlank()) add(Route(segmentsOf(pattern), screen))
|
|
115
|
+
if (screen.isNotBlank()) add(Route(segmentsOf(pattern), screen, constraints?.opt(pattern)))
|
|
113
116
|
}
|
|
114
117
|
}.sortedWith(
|
|
115
118
|
compareBy<Route> { if (it.segments.lastOrNull() == "*") 1 else 0 }
|
|
@@ -189,11 +192,13 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
189
192
|
|
|
190
193
|
/** The screen that draws `path`, with its parameters and query as params. */
|
|
191
194
|
fun match(path: String): CraftNativeRouteMatch? {
|
|
195
|
+
if (!validConstraints) return null
|
|
192
196
|
val bare = normalize(path.substringBefore('#').substringBefore('?'))
|
|
193
197
|
val query = path.substringBefore('#').substringAfter('?', "")
|
|
194
198
|
val parts = segmentsOf(bare)
|
|
195
199
|
for (route in routes) {
|
|
196
200
|
val params = bind(route.segments, parts) ?: continue
|
|
201
|
+
if (!parametersAllowed(params, route.constraints)) continue
|
|
197
202
|
if (query.isNotBlank()) {
|
|
198
203
|
query.split('&').filter { it.isNotBlank() }.forEach { pair ->
|
|
199
204
|
val key = Uri.decode(pair.substringBefore('='))
|
|
@@ -205,6 +210,33 @@ internal class CraftNativeRoutes(config: JSONObject) {
|
|
|
205
210
|
return null
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
private fun parametersAllowed(params: JSONObject, raw: Any?): Boolean {
|
|
214
|
+
if (raw == null) return true
|
|
215
|
+
val rules = raw as? JSONObject ?: return false
|
|
216
|
+
for (name in rules.keys()) {
|
|
217
|
+
var value = params.opt(name) as? String ?: return false
|
|
218
|
+
val rule = rules.optJSONObject(name) ?: return false
|
|
219
|
+
if (rule.keys().asSequence().any { it !in setOf("pattern", "exclude", "normalize") }) return false
|
|
220
|
+
if (rule.has("normalize") && rule.opt("normalize") != "lowercase") return false
|
|
221
|
+
val normalize = rule.has("normalize")
|
|
222
|
+
if (normalize) value = value.lowercase(java.util.Locale.ROOT)
|
|
223
|
+
if (rule.has("exclude")) {
|
|
224
|
+
val excluded = rule.optJSONArray("exclude") ?: return false
|
|
225
|
+
for (index in 0 until excluded.length()) {
|
|
226
|
+
val item = excluded.opt(index) as? String ?: return false
|
|
227
|
+
if (value == if (normalize) item.lowercase(java.util.Locale.ROOT) else item) return false
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (rule.has("pattern")) {
|
|
231
|
+
val pattern = rule.opt("pattern") as? String ?: return false
|
|
232
|
+
val expression = try { Regex(pattern) } catch (_: Exception) { return false }
|
|
233
|
+
if (!expression.matches(value)) return false
|
|
234
|
+
}
|
|
235
|
+
params.put(name, value)
|
|
236
|
+
}
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
|
|
208
240
|
private fun bind(pattern: List<String>, parts: List<String>): JSONObject? {
|
|
209
241
|
val params = JSONObject()
|
|
210
242
|
for (index in pattern.indices) {
|
|
@@ -739,44 +771,48 @@ internal object CraftNativePrelude {
|
|
|
739
771
|
// Native capability ownership wins over any browser-shaped dialog
|
|
740
772
|
// object the bundle installed before the bridge ran. Otherwise an
|
|
741
773
|
// action sheet silently falls back to the DOM renderer.
|
|
742
|
-
var
|
|
743
|
-
function
|
|
774
|
+
var nextCapabilityRequest = 0;
|
|
775
|
+
function nativeRequest(module, method, args, untimed) {
|
|
744
776
|
return new Promise(function(resolve, reject) {
|
|
745
|
-
var id = 'craft-
|
|
777
|
+
var id = 'craft-host-' + (++nextCapabilityRequest);
|
|
746
778
|
var remove = __stxNativeBridge.addMessageListener(function(raw) {
|
|
747
779
|
var message;
|
|
748
780
|
try { message = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (error) { return; }
|
|
749
781
|
var payload = message && message.payload;
|
|
750
782
|
if (!payload || payload.requestId !== id) return;
|
|
783
|
+
if (message.type !== 'API_ERROR' && message.type !== 'API_RESPONSE') return;
|
|
751
784
|
remove();
|
|
752
785
|
if (message.type === 'API_ERROR') {
|
|
753
|
-
var failure = new Error(payload.message || 'Native
|
|
786
|
+
var failure = new Error(payload.message || 'Native capability failed');
|
|
754
787
|
failure.code = payload.code || 'NATIVE_CALL_FAILED';
|
|
755
788
|
reject(failure);
|
|
756
789
|
} else if (message.type === 'API_RESPONSE') {
|
|
757
790
|
resolve(payload.data === undefined ? null : payload.data);
|
|
758
791
|
}
|
|
759
792
|
});
|
|
760
|
-
__stxNativeBridge.postMessage(JSON.stringify({
|
|
793
|
+
try { __stxNativeBridge.postMessage(JSON.stringify({
|
|
761
794
|
type: 'API_REQUEST',
|
|
762
795
|
id: id,
|
|
763
796
|
payload: {
|
|
764
797
|
version: __stxNativeBridge.capabilityProtocolVersion || 1,
|
|
765
|
-
module:
|
|
798
|
+
module: module,
|
|
766
799
|
method: method,
|
|
767
800
|
args: args,
|
|
768
|
-
untimed: true,
|
|
801
|
+
untimed: untimed === true,
|
|
769
802
|
},
|
|
770
|
-
}));
|
|
803
|
+
})); } catch (error) { remove(); reject(error); }
|
|
771
804
|
});
|
|
772
805
|
}
|
|
773
806
|
function installNativeDialog() {
|
|
774
807
|
var craft = globalThis.craft = globalThis.craft || {};
|
|
808
|
+
craft.accessibility = {
|
|
809
|
+
announce: function(text, options) { return nativeRequest('Accessibility', 'announce', [text, options == null ? {} : options], false).then(function() {}); },
|
|
810
|
+
};
|
|
775
811
|
if (Array.isArray(__stxNativeBridge.capabilities) && __stxNativeBridge.capabilities.indexOf('dialog') >= 0) {
|
|
776
812
|
craft.dialog = {
|
|
777
|
-
alert: function(options) { return
|
|
778
|
-
confirm: function(options) { return
|
|
779
|
-
actionSheet: function(options) { return
|
|
813
|
+
alert: function(options) { return nativeRequest('Dialog', 'alert', [options || {}], true).then(function() {}); },
|
|
814
|
+
confirm: function(options) { return nativeRequest('Dialog', 'confirm', [options || {}], true).then(Boolean); },
|
|
815
|
+
actionSheet: function(options) { return nativeRequest('Dialog', 'actionSheet', [options || {}], true).then(function(id) { return typeof id === 'string' ? id : null; }); },
|
|
780
816
|
};
|
|
781
817
|
}
|
|
782
818
|
}
|
|
@@ -1334,6 +1370,12 @@ internal object CraftNativeSymbols {
|
|
|
1334
1370
|
* transfer finishes on its own; the next keep answers from the copy.
|
|
1335
1371
|
*/
|
|
1336
1372
|
internal object CraftNativeMedia {
|
|
1373
|
+
/** Caller-owned account/resource/version identity; the request retains its authorized URL. */
|
|
1374
|
+
fun identity(source: String, options: JSONObject?): String {
|
|
1375
|
+
val key = options?.opt("cacheKey") as? String
|
|
1376
|
+
return if (key != null && key.isNotEmpty() && key.toByteArray().size <= 1024) "craft-media-key:$key" else source
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1337
1379
|
const val ATTEMPT_SETTLED = "craft.media.attemptSettled"
|
|
1338
1380
|
private const val TAG = "craft-media"
|
|
1339
1381
|
private const val PREFS = "craft.media"
|
|
@@ -1373,14 +1415,22 @@ internal object CraftNativeMedia {
|
|
|
1373
1415
|
/** The unique job for an address and connection rule: `any` or `wifi`. */
|
|
1374
1416
|
private fun workName(address: String, wifiOnly: Boolean) = "$TAG:${digest(address)}:${if (wifiOnly) "wifi" else "any"}"
|
|
1375
1417
|
|
|
1376
|
-
fun keep(context: Context,
|
|
1377
|
-
val
|
|
1418
|
+
fun keep(context: Context, source: String, headers: JSONObject, cellular: Boolean, cacheKey: String? = null, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
1419
|
+
val address = identity(source, JSONObject().put("cacheKey", cacheKey))
|
|
1420
|
+
val uri = runCatching { Uri.parse(source) }.getOrNull()
|
|
1378
1421
|
if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https") || uri.host.isNullOrBlank()) {
|
|
1379
1422
|
return done(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "media.keep needs an absolute http or https URL"))
|
|
1380
1423
|
}
|
|
1381
1424
|
val app = context.applicationContext
|
|
1425
|
+
var sourceChanged = false
|
|
1382
1426
|
val started = synchronized(lock) {
|
|
1383
1427
|
local(app, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
|
|
1428
|
+
if (cacheKey != null) {
|
|
1429
|
+
val sourceKey = "source:${digest(address)}"
|
|
1430
|
+
val authorization = digest(source + "\n" + headers.keys().asSequence().sorted().joinToString("\n") { "$it:${headers.optString(it)}" })
|
|
1431
|
+
sourceChanged = prefs(app).getString(sourceKey, null) != authorization
|
|
1432
|
+
if (!prefs(app).edit().putString(sourceKey, authorization).commit()) return done(CraftNativeCapabilityResult(code = "IO_ERROR", message = "Could not persist media authorization"))
|
|
1433
|
+
}
|
|
1384
1434
|
generation(app).also { waiting.getOrPut(address) { mutableListOf() }.add(Waiting(it, done)) }
|
|
1385
1435
|
}
|
|
1386
1436
|
val wifiOnly = !cellular
|
|
@@ -1389,7 +1439,7 @@ internal object CraftNativeMedia {
|
|
|
1389
1439
|
work.cancelUniqueWork(workName(address, !wifiOnly))
|
|
1390
1440
|
val request = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
1391
1441
|
.setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED).build())
|
|
1392
|
-
.setInputData(androidx.work.workDataOf("address" to address, "headers" to headers.toString(), "generation" to started))
|
|
1442
|
+
.setInputData(androidx.work.workDataOf("address" to address, "source" to source, "headers" to headers.toString(), "generation" to started))
|
|
1393
1443
|
.setBackoffCriteria(androidx.work.BackoffPolicy.EXPONENTIAL, 30, java.util.concurrent.TimeUnit.SECONDS)
|
|
1394
1444
|
.addTag(TAG)
|
|
1395
1445
|
.addTag("$TAG:generation:$started")
|
|
@@ -1402,7 +1452,8 @@ internal object CraftNativeMedia {
|
|
|
1402
1452
|
try {
|
|
1403
1453
|
if (generation(app) == started) {
|
|
1404
1454
|
val pending = work.getWorkInfosForUniqueWork(name).get().any {
|
|
1405
|
-
"$TAG:generation:$started" in it.tags &&
|
|
1455
|
+
"$TAG:generation:$started" in it.tags &&
|
|
1456
|
+
!sourceChanged && !it.progress.getBoolean(ATTEMPT_SETTLED, false) &&
|
|
1406
1457
|
(it.state == androidx.work.WorkInfo.State.RUNNING ||
|
|
1407
1458
|
(it.state == androidx.work.WorkInfo.State.ENQUEUED && it.runAttemptCount == 0))
|
|
1408
1459
|
}
|
|
@@ -1450,7 +1501,7 @@ internal object CraftNativeMedia {
|
|
|
1450
1501
|
* file where it stopped, moves it into place whole, and answers whoever
|
|
1451
1502
|
* waits. RETRY leaves the part file for the next attempt.
|
|
1452
1503
|
*/
|
|
1453
|
-
fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, beforeAnswer: () -> Unit = {}, stopped: () -> Boolean): Outcome {
|
|
1504
|
+
fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, source: String = address, beforeAnswer: () -> Unit = {}, stopped: () -> Boolean): Outcome {
|
|
1454
1505
|
fun answer(result: CraftNativeCapabilityResult) {
|
|
1455
1506
|
// The callback can immediately call keep again. Publish completion
|
|
1456
1507
|
// before that callback, while WorkManager may still say RUNNING.
|
|
@@ -1485,8 +1536,8 @@ internal object CraftNativeMedia {
|
|
|
1485
1536
|
val asked = headers.keys().asSequence().associateWith { headers.optString(it) }
|
|
1486
1537
|
val fetched = try {
|
|
1487
1538
|
// A part that no longer lines up was dropped: the whole file, now, not after a backoff.
|
|
1488
|
-
download.fetch(
|
|
1489
|
-
if (first is CraftResumableDownload.Fetched.Restart) download.fetch(
|
|
1539
|
+
download.fetch(source, asked, staging, stopped).let { first ->
|
|
1540
|
+
if (first is CraftResumableDownload.Fetched.Restart) download.fetch(source, asked, staging, stopped) else first
|
|
1490
1541
|
}
|
|
1491
1542
|
} catch (error: Exception) {
|
|
1492
1543
|
val full = generateSequence<Throwable>(error) { it.cause }.take(16).any {
|
|
@@ -1684,7 +1735,7 @@ internal class CraftNativeMediaWorker(context: Context, parameters: androidx.wor
|
|
|
1684
1735
|
val started = inputData.getInt("generation", 0)
|
|
1685
1736
|
return try {
|
|
1686
1737
|
setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to false)).get()
|
|
1687
|
-
val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, started, runAttemptCount,
|
|
1738
|
+
val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, started, runAttemptCount, source = inputData.getString("source") ?: address,
|
|
1688
1739
|
beforeAnswer = { setProgressAsync(androidx.work.workDataOf(CraftNativeMedia.ATTEMPT_SETTLED to true)).get() }
|
|
1689
1740
|
) { isStopped }
|
|
1690
1741
|
if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
|