@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.
@@ -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,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")) add("background")
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 nextDialogRequest = 0;
743
- function dialogRequest(method, args) {
774
+ var nextCapabilityRequest = 0;
775
+ function nativeRequest(module, method, args, untimed) {
744
776
  return new Promise(function(resolve, reject) {
745
- var id = 'craft-dialog-' + (++nextDialogRequest);
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 dialog failed');
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: 'Dialog',
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 dialogRequest('alert', [options || {}]).then(function() {}); },
778
- confirm: function(options) { return dialogRequest('confirm', [options || {}]).then(Boolean); },
779
- actionSheet: function(options) { return dialogRequest('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); },
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, address: String, headers: JSONObject, cellular: Boolean, done: (CraftNativeCapabilityResult) -> Unit) {
1377
- val uri = runCatching { Uri.parse(address) }.getOrNull()
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 && !it.progress.getBoolean(ATTEMPT_SETTLED, false) &&
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(address, asked, staging, stopped).let { first ->
1489
- if (first is CraftResumableDownload.Fetched.Restart) download.fetch(address, asked, staging, stopped) else first
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()