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.
- package/dist/android/templates/CraftAppLinks.kt.template +28 -0
- package/dist/android/templates/CraftBridge.kt.template +3 -29
- package/dist/android/templates/CraftNativeBackground.kt.template +21 -2
- package/dist/android/templates/CraftNativeCapabilities.kt.template +145 -28
- package/dist/android/templates/CraftNativeShell.kt.template +49 -9
- package/dist/android/templates/CraftNativeSounds.kt.template +22 -3
- package/dist/android/templates/MainActivityNative.kt.template +19 -13
- package/dist/browser.js +27 -5
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +8 -1
- package/dist/ios/templates/CraftHybrid.swift +135 -49
- package/dist/ios/templates/CraftNativeActions.swift +47 -3
- package/dist/ios/templates/CraftNativeFiles.swift +8 -1
- package/dist/ios/templates/CraftNativeScreen.swift +46 -11
- package/dist/ios/templates/CraftPersistentStorage.swift +28 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
5355
|
-
val json =
|
|
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
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
1463
|
+
val primarySelected = accessorySpec?.let { isAccessoryActive(it) } == true
|
|
1424
1464
|
accessory.isSelected = primarySelected
|
|
1425
1465
|
companionViews.forEach { (action, item) ->
|
|
1426
|
-
val selected =
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
8727
|
-
|
|
8728
|
-
"
|
|
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
|
-
|
|
8869
|
-
|
|
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
|
}
|