craft-native 0.0.496 → 0.0.499

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.
@@ -17,6 +17,34 @@ internal fun craftAppLinkPathMatches(pattern: String, path: String): Boolean {
17
17
 
18
18
  /** Ordered website path rules, also publishable as Android 15 dynamic App Links. */
19
19
  internal object CraftAppLinks {
20
+ /**
21
+ * A link as a screen or a page hears it.
22
+ *
23
+ * A link is whatever its sender wrote. One written without `//`
24
+ * (`myapp:open?x=1`) is what Android calls opaque: it has no path or
25
+ * query of its own, and asking for its parameters throws. Both hosts
26
+ * asked on every link, on the main thread, so the app closed on being
27
+ * handed one. Its path and query are read from what follows the scheme,
28
+ * which is how UIKit reads the same link. A parameter named twice gives
29
+ * its first value.
30
+ */
31
+ fun describe(uri: Uri): JSONObject {
32
+ val rest = if (uri.isOpaque) uri.encodedSchemeSpecificPart.orEmpty() else null
33
+ val asked = if (rest == null) uri else Uri.parse("craft://link/?" + rest.substringAfter('?', ""))
34
+ // A link with nothing after its `?`, or no `?`, has no parameters;
35
+ // asked anyway, Android answers one with an empty name.
36
+ val named = if (asked.encodedQuery.isNullOrEmpty()) emptySet() else asked.queryParameterNames
37
+ return JSONObject()
38
+ .put("url", uri.toString())
39
+ .put("scheme", uri.scheme ?: "")
40
+ .put("host", uri.host ?: "")
41
+ .put("path", if (rest == null) uri.path ?: "" else Uri.decode(rest.substringBefore('?')))
42
+ .put("query", asked.query ?: "")
43
+ .put("queryParams", JSONObject().apply {
44
+ named.forEach { name -> put(name, asked.getQueryParameter(name) ?: "") }
45
+ })
46
+ }
47
+
20
48
  fun opensInBrowser(config: JSONObject?, uri: Uri): Boolean {
21
49
  if (uri.scheme != "https") return false
22
50
  val hosts = config?.optJSONArray("appLinks") ?: return false
@@ -5351,20 +5351,8 @@ class CraftBridge(
5351
5351
 
5352
5352
  activity.runOnUiThread {
5353
5353
  if (initialURL != null) {
5354
- val uri = Uri.parse(initialURL)
5355
- val json = JSONObject().apply {
5356
- put("url", initialURL)
5357
- put("scheme", uri.scheme ?: "")
5358
- put("host", uri.host ?: "")
5359
- put("path", uri.path ?: "")
5360
- put("query", uri.query ?: "")
5361
- // Parse query parameters
5362
- val queryParams = JSONObject()
5363
- uri.queryParameterNames.forEach { name ->
5364
- queryParams.put(name, uri.getQueryParameter(name) ?: "")
5365
- }
5366
- put("queryParams", queryParams)
5367
- }
5354
+ // Described where every link is: a link without `//` threw here.
5355
+ val json = CraftAppLinks.describe(Uri.parse(initialURL)).put("url", initialURL)
5368
5356
  evaluatePromiseJavascript(
5369
5357
  "window._craftDeepLinkResolve && window._craftDeepLinkResolve($json)"
5370
5358
  )
@@ -5415,21 +5403,7 @@ class CraftBridge(
5415
5403
  private fun dispatchDeepLink(url: String, initial: Boolean) {
5416
5404
  if (nativeDeepLinks && CraftNative.dispatchDeepLink(url, initial)) return
5417
5405
 
5418
- val uri = Uri.parse(url)
5419
- val json = JSONObject().apply {
5420
- put("url", url)
5421
- put("scheme", uri.scheme ?: "")
5422
- put("host", uri.host ?: "")
5423
- put("path", uri.path ?: "")
5424
- put("query", uri.query ?: "")
5425
- // Parse query parameters
5426
- val queryParams = JSONObject()
5427
- uri.queryParameterNames.forEach { name ->
5428
- queryParams.put(name, uri.getQueryParameter(name) ?: "")
5429
- }
5430
- put("queryParams", queryParams)
5431
- put("initial", initial)
5432
- }
5406
+ val json = CraftAppLinks.describe(Uri.parse(url)).put("url", url).put("initial", initial)
5433
5407
 
5434
5408
  evaluateJavascriptUnlessClosed(
5435
5409
  "window.dispatchEvent(new CustomEvent('craftDeepLink', {detail: $json}));"
@@ -251,6 +251,23 @@ internal class CraftNativeHeadlessApp(
251
251
  private val timers = mutableMapOf<Int, Runnable>()
252
252
  private val chunks = CraftNativeChunks()
253
253
 
254
+ /**
255
+ * The clock's reading at this script's first frame, as a screen keeps
256
+ * (`Screen.frameTimeOrigin` in the activity).
257
+ *
258
+ * `requestAnimationFrame` hands its callback milliseconds from the
259
+ * script's own origin. This host handed it milliseconds from boot, so
260
+ * code shared with a screen read a first frame near zero there and one
261
+ * in the billions here, on the same device.
262
+ */
263
+ private var frameTimeOrigin: Long? = null
264
+
265
+ /** Milliseconds from this script's first frame, for a reading of the monotonic clock in nanoseconds. */
266
+ private fun frameTime(now: Long): Double {
267
+ val origin = frameTimeOrigin ?: now.also { frameTimeOrigin = it }
268
+ return (now - origin) / 1_000_000.0
269
+ }
270
+
254
271
  init {
255
272
  capabilities.storeObserver = { store, key, value -> stores.changed(store, key, value, scope) }
256
273
  services.onBackgroundComplete = { ok, answered -> if (answered == null || answered == id) finish(ok) }
@@ -273,7 +290,8 @@ internal class CraftNativeHeadlessApp(
273
290
  if (entry.message.startsWith(prefix)) chunks.accept(entry.message.removePrefix(prefix))?.let(::receive)
274
291
  else Log.i(TAG, "[background] ${entry.message}")
275
292
  })
276
- CraftNativeHub.register(scope) { type, payload -> send(type, payload) }
293
+ // Not able to hear yet: its prelude is built and run below.
294
+ CraftNativeHub.registerWaiting(scope) { type, payload -> send(type, payload) }
277
295
  val names = capabilities.names().apply { services.names().forEach { put(it) } }
278
296
  val bridgeFields = """
279
297
  platform: 'android',
@@ -300,6 +318,7 @@ internal class CraftNativeHeadlessApp(
300
318
  if (closed) return@addListener
301
319
  try {
302
320
  setup.get()
321
+ CraftNativeHub.ready(scope)
303
322
  val load = created.evaluateJavaScriptAsync(bundle)
304
323
  load.addListener({
305
324
  try {
@@ -349,7 +368,7 @@ internal class CraftNativeHeadlessApp(
349
368
  }
350
369
  "TIMER_CLEAR" -> timers.remove(payload.optInt("id"))?.let(main::removeCallbacks)
351
370
  // Nothing is drawn in the background: frames come at a calm pace.
352
- "FRAME" -> main.postDelayed({ send("FRAME_FIRE", JSONObject().put("time", android.os.SystemClock.uptimeMillis().toDouble())) }, 100)
371
+ "FRAME" -> main.postDelayed({ send("FRAME_FIRE", JSONObject().put("time", frameTime(System.nanoTime()))) }, 100)
353
372
  // A background refresh shows nothing: navigation waits for the app.
354
373
  }
355
374
  }
@@ -276,7 +276,10 @@ internal class CraftNativeCapabilities(
276
276
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local database is disabled"), completion)
277
277
  }
278
278
  else databaseExecutor.execute {
279
- complete(requestToken, database(method, args), completion)
279
+ // Asked before the host closed and reached after: it
280
+ // must not reopen what closing is about to give back.
281
+ if (closed.get()) return@execute
282
+ complete(requestToken, database(requestToken, method, args), completion)
280
283
  }
281
284
  }
282
285
  "Lifecycle" -> complete(requestToken, if (method == "getState") success(activity?.nativeAppState ?: "background")
@@ -324,17 +327,42 @@ internal class CraftNativeCapabilities(
324
327
  runCatching { connectivity.unregisterNetworkCallback(networkWatch) }
325
328
  cancelled.clear()
326
329
  if (config.optBoolean("enableBluetooth")) bluetooth.disconnect()
327
- databaseExecutor.shutdownNow()
330
+ closeDatabase()
328
331
  networkExecutor.shutdownNow()
329
332
  fetchConnections.values.forEach(HttpURLConnection::disconnect)
330
333
  fetchConnections.clear()
331
334
  onMain { dismissFilePick(); pendingPedometerPermission = null; pedometerPermissionInFlight = false }
332
- database?.close()
333
- database = null
334
335
  biometricPrompts.values.forEach { it.cancelAuthentication() }
335
336
  biometricPrompts.clear()
336
337
  }
337
338
 
339
+ /**
340
+ * The database is closed on its own thread, and a transaction this host
341
+ * left open is rolled back there first.
342
+ *
343
+ * A transaction belongs to the thread that began it. Closing from the
344
+ * caller's thread could not end it, so its connection stayed open where
345
+ * nothing could reach it again, holding the write lock on the file. Each
346
+ * host has its own connection to that one file: the next activity, or
347
+ * the script started for a background refresh, answered every call with
348
+ * "database is locked" until the lost connection happened to be
349
+ * collected. Calls still queued behind this one are answering a closed
350
+ * host and do nothing.
351
+ */
352
+ private fun closeDatabase() {
353
+ runCatching {
354
+ databaseExecutor.execute {
355
+ val db = database ?: return@execute
356
+ database = null
357
+ transactionScope = null
358
+ runCatching { if (db.inTransaction()) db.endTransaction() }
359
+ .onFailure { android.util.Log.w("CraftNativeAndroid", "A closed host's transaction could not be rolled back", it) }
360
+ runCatching { db.close() }
361
+ }
362
+ }
363
+ databaseExecutor.shutdown()
364
+ }
365
+
338
366
  fun pedometerPermissionGranted(): Boolean = checkPedometerPermission?.invoke() ?: (Build.VERSION.SDK_INT < 29 ||
339
367
  ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == PackageManager.PERMISSION_GRANTED)
340
368
 
@@ -535,18 +563,7 @@ internal class CraftNativeCapabilities(
535
563
 
536
564
  private fun files(requestToken: String, method: String, args: JSONArray): CraftNativeCapabilityResult {
537
565
  return when (method) {
538
- "write" -> {
539
- val name = args.optString(0).trim()
540
- if (name.isBlank()) return failure("INVALID_ARGUMENT", "files.write needs a file name")
541
- val data = args.optString(1, "")
542
- val options = args.optJSONObject(2) ?: JSONObject()
543
- val bytes = when (options.optString("encoding", "utf8")) {
544
- "utf8" -> data.toByteArray(Charsets.UTF_8)
545
- "base64" -> decodeBase64(data) ?: return failure("INVALID_ARGUMENT", "files.write base64 is invalid")
546
- else -> return failure("INVALID_ARGUMENT", "files.write encoding is 'utf8' or 'base64'")
547
- }
548
- store(bytes, name, options.optString("type").ifBlank { URLConnection.guessContentTypeFromName(name) ?: "application/octet-stream" })
549
- }
566
+ "write" -> write(args)
550
567
  "remove" -> {
551
568
  val file = ownedFile(args.optString(0)) ?: return failure("INVALID_ARGUMENT", "files.remove needs a file made by craft.files")
552
569
  success(removeOwnedFile(file))
@@ -556,6 +573,30 @@ internal class CraftNativeCapabilities(
556
573
  }
557
574
  }
558
575
 
576
+ /**
577
+ * What a screen made itself, written as a file: text, or bytes as base64.
578
+ *
579
+ * The name and the contents are strings, and nothing else is read as
580
+ * one. `optString` reads anything: a number became its digits, `null`
581
+ * the four letters "null", and bytes handed over as they were (a
582
+ * `Uint8Array` crosses the bridge as `{"0":1,"1":2}`) became that JSON
583
+ * text, written to a file and answered as a success. UIKit refuses each
584
+ * of those, and so does this. An absent or `null` encoding is `utf8`.
585
+ */
586
+ private fun write(args: JSONArray): CraftNativeCapabilityResult {
587
+ val name = (args.opt(0) as? String)?.trim().orEmpty()
588
+ if (name.isBlank()) return failure("INVALID_ARGUMENT", "files.write needs a file name")
589
+ val data = args.opt(1) as? String ?: return failure("INVALID_ARGUMENT", "files.write needs its contents as a string")
590
+ val options = args.optJSONObject(2) ?: JSONObject()
591
+ val encoding = if (options.isNull("encoding")) "utf8" else options.opt("encoding") as? String
592
+ val bytes = when (encoding) {
593
+ "utf8" -> data.toByteArray(Charsets.UTF_8)
594
+ "base64" -> decodeBase64(data) ?: return failure("INVALID_ARGUMENT", "files.write base64 is invalid")
595
+ else -> return failure("INVALID_ARGUMENT", "files.write encoding is 'utf8' or 'base64'")
596
+ }
597
+ return store(bytes, name, options.optString("type").ifBlank { URLConnection.guessContentTypeFromName(name) ?: "application/octet-stream" })
598
+ }
599
+
559
600
  /**
560
601
  * Base64 as `atob` reads it, which is what the browser host runs and what
561
602
  * UIKit is held to: the alphabet, ASCII white space anywhere, padding
@@ -1172,7 +1213,12 @@ internal class CraftNativeCapabilities(
1172
1213
  "compareAndSet" -> {
1173
1214
  if (args.length() != 3) return failure("INVALID_ARGUMENT", "Storage.compareAndSet needs a key, expected value and next value")
1174
1215
  val current = storage.getString(key, null) ?: return success(false)
1175
- if (!CraftNativeStorage.jsonEqual(JSONTokener(current).nextValue(), args.opt(1))) return success(false)
1216
+ // What `get` answers for a value it cannot read. Unparsed, it
1217
+ // threw from here and reached the screen as STORAGE_ERROR,
1218
+ // the code for a disk that failed.
1219
+ val held = runCatching { JSONTokener(current).nextValue() }
1220
+ .getOrElse { return failure("STORAGE_CORRUPT", "Stored value is not valid JSON") }
1221
+ if (!CraftNativeStorage.jsonEqual(held, args.opt(1))) return success(false)
1176
1222
  val next = jsonString(args.opt(2)) ?: return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
1177
1223
  if (!CraftNativeStorage.commit(storage) { it.putString(key, next) }) return failure("STORAGE_ERROR", "Storage could not persist the checkpoint")
1178
1224
  storeObserver?.invoke("storage", key, next)
@@ -1292,7 +1338,50 @@ internal class CraftNativeCapabilities(
1292
1338
  }
1293
1339
  }
1294
1340
 
1295
- private fun database(method: String, args: JSONArray): CraftNativeCapabilityResult {
1341
+ /**
1342
+ * A screen has closed. A transaction it began and never finished is
1343
+ * rolled back.
1344
+ *
1345
+ * The database is one connection shared by every screen, and a
1346
+ * transaction left open on it does not end with the screen that began
1347
+ * it: every later write joins it, reads back as written, and is gone
1348
+ * when the process exits, since nobody is left to commit. Rolling back
1349
+ * loses what the closed screen had not committed, which is what closing
1350
+ * mid-transaction means, and gives every later write its own fate back.
1351
+ * Queued behind whatever that screen had already asked for.
1352
+ */
1353
+ fun endScope(scope: String) {
1354
+ if (closed.get()) return
1355
+ runCatching {
1356
+ databaseExecutor.execute {
1357
+ if (transactionScope != scope) return@execute
1358
+ transactionScope = null
1359
+ val db = database ?: return@execute
1360
+ runCatching { if (db.inTransaction()) db.endTransaction() }
1361
+ .onFailure { android.util.Log.w("CraftNativeAndroid", "A closed screen's transaction could not be rolled back", it) }
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ /**
1367
+ * Which screen's transaction is open, read off the database around each
1368
+ * call rather than off the method's name: a call that leaves a
1369
+ * transaction open where there was none began it, and one that leaves
1370
+ * none ended it. The scope is the part of a request token before its
1371
+ * last `/`. Only touched on the database thread.
1372
+ */
1373
+ private var transactionScope: String? = null
1374
+
1375
+ private fun database(requestToken: String, method: String, args: JSONArray): CraftNativeCapabilityResult {
1376
+ val before = runCatching { database?.inTransaction() == true }.getOrDefault(false)
1377
+ val answer = databaseCall(method, args)
1378
+ val after = runCatching { database?.inTransaction() == true }.getOrDefault(false)
1379
+ if (after && !before) transactionScope = requestToken.substringBeforeLast('/', "")
1380
+ else if (!after) transactionScope = null
1381
+ return answer
1382
+ }
1383
+
1384
+ private fun databaseCall(method: String, args: JSONArray): CraftNativeCapabilityResult {
1296
1385
  val db = database ?: runCatching {
1297
1386
  context.openOrCreateDatabase("craft-native.sqlite", Context.MODE_PRIVATE, null)
1298
1387
  }.getOrElse { return failure("DATABASE_ERROR", it.message ?: "Could not open the local database") }
@@ -1309,7 +1398,12 @@ internal class CraftNativeCapabilities(
1309
1398
  if (sql.isEmpty()) return failure("INVALID_ARGUMENT", "Database.query needs a SQL statement")
1310
1399
  query(db, sql, args.optJSONArray(1) ?: JSONArray())
1311
1400
  }
1312
- "beginTransaction" -> { db.beginTransaction(); success(true) }
1401
+ // SQLite refuses a transaction inside a transaction, and that
1402
+ // is UIKit's answer. Android would nest it instead: the inner
1403
+ // `commit` then makes nothing durable and reports that it did,
1404
+ // and the outer one is some other caller's to finish.
1405
+ "beginTransaction" -> if (db.inTransaction()) failure("DATABASE_ERROR", "cannot start a transaction within a transaction")
1406
+ else { db.beginTransaction(); success(true) }
1313
1407
  "commit" -> {
1314
1408
  if (!db.inTransaction()) failure("DATABASE_ERROR", "No transaction is active")
1315
1409
  else { db.setTransactionSuccessful(); db.endTransaction(); success(true) }
@@ -1435,13 +1529,10 @@ internal class CraftNativeCapabilities(
1435
1529
  "cancel" -> {
1436
1530
  val id = validKey(args.opt(0))
1437
1531
  complete(requestToken, if (id == null) failure("INVALID_ARGUMENT", "Notifications.cancel needs an id")
1438
- else { cancelNotification(id); success(true) }, completion)
1439
- }
1440
- "cancelAll" -> {
1441
- notificationStorage.all.keys.forEach(::cancelNotification)
1442
- CraftNativeNotifications.cancelAll(context)
1443
- complete(requestToken, success(true), completion)
1532
+ else if (cancelNotification(id)) success(true)
1533
+ else failure("NOTIFICATION_ERROR", "The notification could not be cancelled"), completion)
1444
1534
  }
1535
+ "cancelAll" -> complete(requestToken, cancelEveryNotification(), completion)
1445
1536
  "pending" -> complete(requestToken, success(JSONArray(notificationStorage.all.values.mapNotNull {
1446
1537
  runCatching { JSONObject(it as String) }.getOrNull()
1447
1538
  })), completion)
@@ -1467,7 +1558,13 @@ internal class CraftNativeCapabilities(
1467
1558
  val id = validKey(value?.opt("id")) ?: UUID.randomUUID().toString()
1468
1559
  val fireAt = System.currentTimeMillis() + value!!.optLong("delay", 0).coerceAtLeast(0)
1469
1560
  val stored = JSONObject(value.toString()).put("id", id).put("fireAt", fireAt)
1470
- notificationStorage.edit().putString(id, stored.toString()).commit()
1561
+ // What is stored under the id is what the receiver shows when the
1562
+ // alarm fires, usually in a process started for it. Scheduled without
1563
+ // being saved, the alarm fired, found nothing and showed nothing,
1564
+ // after this had answered with the id.
1565
+ if (!CraftNativeStorage.commit(notificationStorage) { it.putString(id, stored.toString()) }) {
1566
+ return failure("NOTIFICATION_ERROR", "The notification could not be saved")
1567
+ }
1471
1568
  scheduleStored(stored)
1472
1569
  return success(id)
1473
1570
  }
@@ -1489,14 +1586,34 @@ internal class CraftNativeCapabilities(
1489
1586
  )
1490
1587
  }
1491
1588
 
1492
- private fun cancelNotification(id: String) {
1589
+ /**
1590
+ * Cancels a scheduled notification. False when its removal did not reach
1591
+ * the disk, and then nothing is cancelled.
1592
+ *
1593
+ * Every start schedules whatever is stored (`restoreNotifications`). The
1594
+ * alarm used to be cancelled first and the failed removal ignored, so a
1595
+ * reminder looked cancelled for the life of the process and was
1596
+ * scheduled again by the next one. It is removed from storage first, and
1597
+ * one that cannot be removed stays as it was, alarm included, and the
1598
+ * call says so.
1599
+ */
1600
+ private fun cancelNotification(id: String): Boolean {
1601
+ if (!CraftNativeStorage.commit(notificationStorage) { it.remove(id) }) return false
1493
1602
  CraftNativeNotifications.cancel(context, id)
1494
1603
  val alarm = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
1495
1604
  val intent = notificationIntent(id)
1496
1605
  alarm.cancel(intent)
1497
1606
  intent.cancel()
1498
- notificationStorage.edit().remove(id).commit()
1499
1607
  (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).cancel(id, 0)
1608
+ return true
1609
+ }
1610
+
1611
+ /** `cancelAll`: every scheduled notification, and an error naming how many could not be. */
1612
+ private fun cancelEveryNotification(): CraftNativeCapabilityResult {
1613
+ val left = notificationStorage.all.keys.toList().filterNot(::cancelNotification)
1614
+ CraftNativeNotifications.cancelAll(context)
1615
+ return if (left.isEmpty()) success(true)
1616
+ else failure("NOTIFICATION_ERROR", "${left.size} scheduled notifications could not be cancelled")
1500
1617
  }
1501
1618
 
1502
1619
  private fun notificationIntent(id: String): PendingIntent = PendingIntent.getBroadcast(
@@ -58,12 +58,56 @@ internal object CraftNativeHub {
58
58
  private val receivers = ConcurrentHashMap<String, (String, JSONObject) -> Unit>()
59
59
  private val main = Handler(Looper.getMainLooper())
60
60
 
61
+ /** What was broadcast to an isolate that could not hear yet, in order. Guarded by itself. */
62
+ private val waiting = HashMap<String, ArrayList<Pair<String, JSONObject>>>()
63
+
61
64
  fun register(id: String, receiver: (String, JSONObject) -> Unit) {
62
65
  receivers[id] = receiver
63
66
  }
64
67
 
68
+ /**
69
+ * Registers an isolate whose prelude has not run yet.
70
+ *
71
+ * Such an isolate is registered before it is given its copy of the
72
+ * stores, and the copy is taken some time later, off the main thread. A
73
+ * change broadcast in between has nowhere to land: the prelude defines
74
+ * what receives it, so a message sent before the prelude runs is
75
+ * discarded, and the isolate goes on to start from a copy that may
76
+ * predate the change (#507). What is broadcast meanwhile is kept instead
77
+ * and handed over, in order, by [ready]. Changes are broadcast in the
78
+ * order they are made, so replaying them over a copy taken part-way
79
+ * through ends on the latest value.
80
+ */
81
+ fun registerWaiting(id: String, receiver: (String, JSONObject) -> Unit) {
82
+ synchronized(waiting) { waiting[id] = ArrayList() }
83
+ receivers[id] = { type, payload ->
84
+ val kept = synchronized(waiting) { waiting[id]?.also { it.add(type to payload) } }
85
+ if (kept == null) receiver(type, payload)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The isolate's prelude has run: it is given what was kept for it, then
91
+ * hears each broadcast as it is made. On the main thread, where
92
+ * broadcasts are delivered, so nothing can arrive between the two.
93
+ */
94
+ fun ready(id: String) {
95
+ val deliver = {
96
+ val kept = synchronized(waiting) { waiting.remove(id) }
97
+ val receiver = receivers[id]
98
+ if (kept != null && receiver != null) {
99
+ for ((type, payload) in kept) {
100
+ runCatching { receiver(type, payload) }
101
+ .onFailure { Log.w("CraftNativeAndroid", "Kept $type to $id failed", it) }
102
+ }
103
+ }
104
+ }
105
+ if (Looper.myLooper() == Looper.getMainLooper()) deliver() else main.post(deliver)
106
+ }
107
+
65
108
  fun unregister(id: String) {
66
109
  receivers.remove(id)
110
+ synchronized(waiting) { waiting.remove(id) }
67
111
  }
68
112
 
69
113
  /** Sends to every registered isolate but `except`, on the main thread. */
@@ -1015,6 +1059,8 @@ internal class CraftNativeChrome(
1015
1059
  val path = item.optString("path")
1016
1060
  return visiblePath == path || visiblePath?.startsWith(path + "/") == true
1017
1061
  }
1062
+ /** Drawn in the accent: its destination is showing, or the app says it is on. */
1063
+ private fun isAccessoryActive(item: JSONObject): Boolean = item.optBoolean("selected") || isAccessorySelected(item)
1018
1064
  private var swipeTracking = false
1019
1065
  private var swipeIntercepted = false
1020
1066
  private var horizontalScrollDeferred = false
@@ -1313,13 +1359,7 @@ internal class CraftNativeChrome(
1313
1359
  action.layoutParams = LayoutParams(dp(48), dp(48)).apply { marginStart = if (grouped) 0 else dp(8) }
1314
1360
  }
1315
1361
  accessory.layoutParams = LayoutParams(dp(if (grouped) 48 else 64), dp(if (grouped) 48 else 64)).apply { marginStart = if (grouped) 0 else dp(8) }
1316
- val divider = GradientDrawable().apply { setColor(Color.argb(48, 128, 128, 128)); setSize(dp(1), dp(1)) }
1317
- for (row in listOf(accessoryGroup, companionRow)) {
1318
- row.showDividers = if (grouped) SHOW_DIVIDER_MIDDLE else SHOW_DIVIDER_NONE
1319
- row.dividerDrawable = if (grouped) divider.constantState?.newDrawable() else null
1320
- row.dividerPadding = dp(12)
1321
- }
1322
- // An empty companion row must not leave a divider above a single icon.
1362
+ // A group is one control: no divider between its actions, as on iOS.
1323
1363
  companionRow.visibility = if (companionViews.isEmpty()) View.GONE else View.VISIBLE
1324
1364
  }
1325
1365
 
@@ -1420,10 +1460,10 @@ internal class CraftNativeChrome(
1420
1460
  val grouped = accessoryGroup.parent === body
1421
1461
  accessoryGroup.background = if (grouped) GradientDrawable().apply { setColor(surface); cornerRadius = dp(26).toFloat() } else null
1422
1462
  tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
1423
- val primarySelected = accessorySpec?.let { isAccessorySelected(it) } == true
1463
+ val primarySelected = accessorySpec?.let { isAccessoryActive(it) } == true
1424
1464
  accessory.isSelected = primarySelected
1425
1465
  companionViews.forEach { (action, item) ->
1426
- val selected = isAccessorySelected(item)
1466
+ val selected = isAccessoryActive(item)
1427
1467
  action.isSelected = selected
1428
1468
  action.background = if (grouped) rippleBackground() else GradientDrawable().apply { setColor(if (selected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1429
1469
  (action.getChildAt(0) as ImageView).setImageDrawable(CraftNativeIcons.drawable(context, null, item.optString("symbol"), if (selected) accent else text))
@@ -187,17 +187,36 @@ internal class CraftNativeSounds(
187
187
  return true
188
188
  }
189
189
 
190
+ /**
191
+ * The fade a `stop` asked for, in milliseconds: a JSON number that is
192
+ * finite and above zero, and otherwise none.
193
+ *
194
+ * `optDouble` also read "500" as a number, where UIKit reads only a
195
+ * number as one.
196
+ */
197
+ fun fadeOut(options: JSONObject?): Double {
198
+ val asked = (options?.opt("fadeOut") as? Number)?.toDouble() ?: return 0.0
199
+ return if (asked.isFinite() && asked > 0) asked else 0.0
200
+ }
201
+
202
+ /**
203
+ * Stops the sound, fading it out over `fadeOut` milliseconds first.
204
+ *
205
+ * Stopping is what was asked, and the fade is how. A `fadeOut` that is
206
+ * not a duration, a negative one say, used to be refused: the call
207
+ * answered `false`, which is the answer for a sound that does not exist,
208
+ * and the sound went on playing. UIKit and the browser host stop it at
209
+ * once, and so does this.
210
+ */
190
211
  fun stop(id: String, fadeOut: Double): Boolean {
191
212
  val sound = sounds[id] ?: return false
192
- if (!fadeOut.isFinite() || fadeOut < 0) return false
193
- if (fadeOut > 0 && !sound.paused) {
213
+ if (fadeOut.isFinite() && fadeOut > 0 && !sound.paused) {
194
214
  ramp(sound, 0f, fadeOut / 1000.0) { if (sounds[id] === sound) remove(id) }
195
215
  } else remove(id)
196
216
  return true
197
217
  }
198
218
 
199
219
  fun stopAll(fadeOut: Double): Boolean {
200
- if (!fadeOut.isFinite() || fadeOut < 0) return false
201
220
  sounds.keys.toList().forEach { stop(it, fadeOut) }
202
221
  return true
203
222
  }
@@ -5635,7 +5635,8 @@ class MainActivity : AppCompatActivity() {
5635
5635
  if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload)
5636
5636
  }
5637
5637
  screen = activeScreen
5638
- CraftNativeHub.register(activeScreen.capabilityScope) { type, payload ->
5638
+ // Not able to hear yet: its prelude is built and run below.
5639
+ CraftNativeHub.registerWaiting(activeScreen.capabilityScope) { type, payload ->
5639
5640
  if (activeScreen.active) send(activeScreen, type, payload)
5640
5641
  }
5641
5642
  if (!headless) {
@@ -5845,6 +5846,9 @@ class MainActivity : AppCompatActivity() {
5845
5846
  if (!activeScreen.active) return@addListener
5846
5847
  try {
5847
5848
  setup.get()
5849
+ // The prelude has run, so the isolate can hear: what changed
5850
+ // while it was being built, before the bundle reads any of it.
5851
+ CraftNativeHub.ready(activeScreen.capabilityScope)
5848
5852
  val source = CraftNativeBundle.source(this, route)
5849
5853
  if (source == null) {
5850
5854
  if (activeScreen.active) showError("Missing native-screen.js. Build with --native-bundle after compiling STX.")
@@ -5891,6 +5895,8 @@ class MainActivity : AppCompatActivity() {
5891
5895
  }
5892
5896
  }, executor)
5893
5897
  } catch (error: Exception) {
5898
+ // Nothing will run in this isolate, so nothing is kept for it.
5899
+ CraftNativeHub.ready(activeScreen.capabilityScope)
5894
5900
  Log.e(TAG, "Native STX bridge failed", error)
5895
5901
  showError("Native STX bridge failed: ${error.message}")
5896
5902
  }
@@ -8723,9 +8729,13 @@ class MainActivity : AppCompatActivity() {
8723
8729
  }
8724
8730
  "pause" -> completion(CraftNativeCapabilityResult(data = screen.sounds.pause(id)))
8725
8731
  "resume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.resume(id)))
8726
- "setVolume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.setVolume(id, args.optDouble(1, Double.NaN), args.optJSONObject(2)?.optDouble("fade", 0.0) ?: 0.0)))
8727
- "stop" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stop(id, args.optJSONObject(1)?.optDouble("fadeOut", 0.0) ?: 0.0)))
8728
- "stopAll" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stopAll(args.optJSONObject(0)?.optDouble("fadeOut", 0.0) ?: 0.0)))
8732
+ // A number is a JSON number: `optDouble` also read "0.5" as one,
8733
+ // where UIKit answers false for a volume given as text.
8734
+ "setVolume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.setVolume(id,
8735
+ (args.opt(1) as? Number)?.toDouble() ?: Double.NaN,
8736
+ (args.optJSONObject(2)?.opt("fade") as? Number)?.toDouble() ?: 0.0)))
8737
+ "stop" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stop(id, screen.sounds.fadeOut(args.optJSONObject(1)))))
8738
+ "stopAll" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stopAll(screen.sounds.fadeOut(args.optJSONObject(0)))))
8729
8739
  else -> completion(CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported Audio method"))
8730
8740
  }
8731
8741
  }
@@ -8865,15 +8875,8 @@ class MainActivity : AppCompatActivity() {
8865
8875
  else -> CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported native API")
8866
8876
  }
8867
8877
 
8868
- internal fun deepLinkData(uri: android.net.Uri): JSONObject = JSONObject()
8869
- .put("url", uri.toString())
8870
- .put("scheme", uri.scheme ?: "")
8871
- .put("host", uri.host ?: "")
8872
- .put("path", uri.path ?: "")
8873
- .put("query", uri.query ?: "")
8874
- .put("queryParams", JSONObject().apply {
8875
- uri.queryParameterNames.forEach { name -> put(name, uri.getQueryParameter(name) ?: "") }
8876
- })
8878
+ /** A link as the screens hear it: `CraftAppLinks.describe`, which the page bridge uses too. */
8879
+ internal fun deepLinkData(uri: android.net.Uri): JSONObject = CraftAppLinks.describe(uri)
8877
8880
 
8878
8881
  /** App-wide news (the app's state, a link): every screen and the app script hear it. */
8879
8882
  private fun publish(type: String, payload: JSONObject) {
@@ -9423,6 +9426,7 @@ class MainActivity : AppCompatActivity() {
9423
9426
  screen.capabilityTimeouts.clear()
9424
9427
  screen.pendingCapabilities.forEach(capabilities::cancel)
9425
9428
  screen.pendingCapabilities.clear()
9429
+ capabilities.endScope(screen.capabilityScope)
9426
9430
  releaseViewState(screen, retainedViews(screen).toList())
9427
9431
  screen.inputWatchers.forEach { (input, watcher) -> input.removeTextChangedListener(watcher) }
9428
9432
  screen.inputWatchers.clear()
@@ -9463,6 +9467,8 @@ class MainActivity : AppCompatActivity() {
9463
9467
  screen.capabilityTimeouts.clear()
9464
9468
  screen.pendingCapabilities.forEach(capabilities::cancel)
9465
9469
  screen.pendingCapabilities.clear()
9470
+ // Again: a transaction can be begun while the isolate is closing.
9471
+ capabilities.endScope(screen.capabilityScope)
9466
9472
  done()
9467
9473
  }
9468
9474
  }