craft-native 0.0.497 → 0.0.500
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/CraftNativeFlatList.kt.template +51 -3
- package/dist/android/templates/CraftNativeShell.kt.template +102 -2
- package/dist/android/templates/CraftNativeSounds.kt.template +22 -3
- package/dist/android/templates/MainActivityNative.kt.template +44 -14
- package/dist/api/process.d.ts +23 -28
- package/dist/bridge/core.d.ts +30 -6
- package/dist/browser.js +27 -5
- package/dist/cli.js +39 -19
- package/dist/components/index.d.ts +1 -1
- package/dist/index.cjs +90 -99
- package/dist/index.js +90 -99
- package/dist/ios/templates/CraftApp.swift +25 -1
- package/dist/ios/templates/CraftHybrid.swift +63 -22
- package/dist/ios/templates/CraftNativeActions.swift +47 -3
- package/dist/ios/templates/CraftNativeFiles.swift +160 -8
- package/dist/ios/templates/CraftNativeFlatList.swift +43 -6
- package/dist/ios/templates/CraftNativeScreen.swift +222 -69
- package/dist/ios/templates/CraftPersistentStorage.swift +28 -5
- package/dist/ios/templates/CraftScheduledWorkouts.swift +11 -4
- package/dist/ios/templates/CraftWebAudio.swift +20 -1
- package/dist/package.d.ts +22 -0
- 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(
|
|
@@ -297,10 +297,38 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
297
297
|
post { onContentSizeChanged?.invoke() }
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* How far the grouped tab actions reach over the end of this list, in
|
|
302
|
+
* pixels (#523). The last row is given that much room below it, so it can
|
|
303
|
+
* be brought clear of them; the rows above still scroll behind them, and
|
|
304
|
+
* the list's own padding and viewport are as they were.
|
|
305
|
+
*/
|
|
306
|
+
private var floatingCover = 0
|
|
307
|
+
|
|
308
|
+
fun setFloatingCover(cover: Int) {
|
|
309
|
+
val next = if (horizontal) 0 else max(0, cover)
|
|
310
|
+
if (next == floatingCover) return
|
|
311
|
+
floatingCover = next
|
|
312
|
+
invalidateItemDecorations()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private val floatingCoverRoom = object : ItemDecoration() {
|
|
316
|
+
override fun getItemOffsets(outRect: android.graphics.Rect, view: View, parent: RecyclerView, state: State) {
|
|
317
|
+
val position = parent.getChildAdapterPosition(view)
|
|
318
|
+
val last = state.itemCount - 1
|
|
319
|
+
if (floatingCover <= 0 || horizontal || position == NO_POSITION || last < 0) return
|
|
320
|
+
val grid = layoutManager as? GridLayoutManager
|
|
321
|
+
val endsTheList = if (grid == null) position == last
|
|
322
|
+
else grid.spanSizeLookup.getSpanGroupIndex(position, grid.spanCount) == grid.spanSizeLookup.getSpanGroupIndex(last, grid.spanCount)
|
|
323
|
+
if (endsTheList) outRect.bottom = floatingCover
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
300
327
|
init {
|
|
301
328
|
adapter = listAdapter
|
|
302
329
|
itemAnimator = null
|
|
303
330
|
addOnScrollListener(scrollListener)
|
|
331
|
+
addItemDecoration(floatingCoverRoom)
|
|
304
332
|
}
|
|
305
333
|
|
|
306
334
|
fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit) {
|
|
@@ -387,11 +415,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
387
415
|
}
|
|
388
416
|
|
|
389
417
|
val seen = mutableMapOf<String, Int>()
|
|
418
|
+
val taken = mutableSetOf<String>()
|
|
390
419
|
val next = nodes.mapIndexed { index, node ->
|
|
391
420
|
val base = identity(node) ?: "index:$index"
|
|
392
|
-
val
|
|
393
|
-
seen[base] = occurrence + 1
|
|
394
|
-
val unique = if (occurrence == 0) base else "$base#$occurrence"
|
|
421
|
+
val unique = uniqueIdentity(base, seen, taken)
|
|
395
422
|
val role = node.optJSONObject("props")?.optString("listRole").orEmpty()
|
|
396
423
|
Item(unique, node, node.toString(), role.isNotBlank() && role != "item")
|
|
397
424
|
}.let { if (inverted) it.reversed() else it }
|
|
@@ -546,6 +573,27 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
546
573
|
endReached?.invoke()
|
|
547
574
|
}
|
|
548
575
|
|
|
576
|
+
/**
|
|
577
|
+
* A row's identity, its own in this list.
|
|
578
|
+
*
|
|
579
|
+
* Rows that share a key are told apart by a suffix: `run`, then `run#1`.
|
|
580
|
+
* A row whose own key was `run#1` then had the identity of the second
|
|
581
|
+
* `run`, and with it the same stable id, which a RecyclerView requires to
|
|
582
|
+
* be unique; UIKit's list ended the process on the same rows. The suffix
|
|
583
|
+
* counts on until the identity is one no row has taken. A list with no
|
|
584
|
+
* such key has the identities it always had.
|
|
585
|
+
*/
|
|
586
|
+
private fun uniqueIdentity(base: String, seen: MutableMap<String, Int>, taken: MutableSet<String>): String {
|
|
587
|
+
var occurrence = seen.getOrDefault(base, 0)
|
|
588
|
+
var identity = if (occurrence == 0) base else "$base#$occurrence"
|
|
589
|
+
while (!taken.add(identity)) {
|
|
590
|
+
occurrence += 1
|
|
591
|
+
identity = "$base#$occurrence"
|
|
592
|
+
}
|
|
593
|
+
seen[base] = occurrence + 1
|
|
594
|
+
return identity
|
|
595
|
+
}
|
|
596
|
+
|
|
549
597
|
private fun identity(node: JSONObject): String? {
|
|
550
598
|
val props = node.optJSONObject("props") ?: JSONObject()
|
|
551
599
|
return listOf(node.optString("id"), node.optString("key"), props.optString("key"), props.optString("testID"))
|
|
@@ -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
|
|
@@ -1289,6 +1335,60 @@ internal class CraftNativeChrome(
|
|
|
1289
1335
|
refreshColors()
|
|
1290
1336
|
}
|
|
1291
1337
|
|
|
1338
|
+
/**
|
|
1339
|
+
* How far the grouped actions reach up from the tab bar, in pixels: their
|
|
1340
|
+
* height and their lift above it. Zero when they are not floating over
|
|
1341
|
+
* the screen (no group, a hidden bar, or actions that sit in the tab row).
|
|
1342
|
+
*/
|
|
1343
|
+
internal fun floatingActionsReach(): Int =
|
|
1344
|
+
if (accessoryGroup.parent === body && accessoryGroup.visibility == View.VISIBLE && accessoryGroup.height > 0) (body.height - accessoryGroup.top).coerceAtLeast(0)
|
|
1345
|
+
else 0
|
|
1346
|
+
|
|
1347
|
+
private var floatingClearancePosted = false
|
|
1348
|
+
private var floatingClearanceGiven = false
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* The grouped actions float over the trailing end of whatever scrolls
|
|
1352
|
+
* behind them, and nothing was reserved, so the last rows of a screen
|
|
1353
|
+
* could not be brought out from under them (#523). Every vertical scroll
|
|
1354
|
+
* view and list that reaches into them is given that much room at its
|
|
1355
|
+
* end; one that ends above them gets none. Measured after each layout,
|
|
1356
|
+
* where each of them now ends.
|
|
1357
|
+
*/
|
|
1358
|
+
private fun clearFloatingActions() {
|
|
1359
|
+
val reach = floatingActionsReach()
|
|
1360
|
+
// Nothing floats and nothing was given room: no screen to walk.
|
|
1361
|
+
if (reach <= 0 && !floatingClearanceGiven) return
|
|
1362
|
+
floatingClearanceGiven = reach > 0
|
|
1363
|
+
val top = body.height - reach
|
|
1364
|
+
val origin = IntArray(2).also { body.getLocationInWindow(it) }
|
|
1365
|
+
val at = IntArray(2)
|
|
1366
|
+
fun cover(view: View): Int {
|
|
1367
|
+
if (reach <= 0 || !view.isLaidOut) return 0
|
|
1368
|
+
view.getLocationInWindow(at)
|
|
1369
|
+
return (at[1] - origin[1] + view.height - top).coerceIn(0, reach)
|
|
1370
|
+
}
|
|
1371
|
+
fun visit(view: View) {
|
|
1372
|
+
when (view) {
|
|
1373
|
+
is CraftNativeFlatList -> view.setFloatingCover(cover(view))
|
|
1374
|
+
is CraftNativeScrollView -> { view.setFloatingCover(cover(view)); visit(view.content) }
|
|
1375
|
+
is ViewGroup -> for (index in 0 until view.childCount) visit(view.getChildAt(index))
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
visit(content)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
1382
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
1383
|
+
if (floatingClearancePosted) return
|
|
1384
|
+
floatingClearancePosted = true
|
|
1385
|
+
// After this pass: giving a scroll view room asks for a layout.
|
|
1386
|
+
post {
|
|
1387
|
+
floatingClearancePosted = false
|
|
1388
|
+
clearFloatingActions()
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1292
1392
|
/** The same actions and popup anchors survive switching between grouped and separate chrome. */
|
|
1293
1393
|
private fun layoutAccessoryGroup(hidden: Boolean) {
|
|
1294
1394
|
val layout = accessorySpec?.optString("layout").orEmpty()
|
|
@@ -1414,10 +1514,10 @@ internal class CraftNativeChrome(
|
|
|
1414
1514
|
val grouped = accessoryGroup.parent === body
|
|
1415
1515
|
accessoryGroup.background = if (grouped) GradientDrawable().apply { setColor(surface); cornerRadius = dp(26).toFloat() } else null
|
|
1416
1516
|
tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
|
|
1417
|
-
val primarySelected = accessorySpec?.let {
|
|
1517
|
+
val primarySelected = accessorySpec?.let { isAccessoryActive(it) } == true
|
|
1418
1518
|
accessory.isSelected = primarySelected
|
|
1419
1519
|
companionViews.forEach { (action, item) ->
|
|
1420
|
-
val selected =
|
|
1520
|
+
val selected = isAccessoryActive(item)
|
|
1421
1521
|
action.isSelected = selected
|
|
1422
1522
|
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() }
|
|
1423
1523
|
(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
|
}
|