craft-native 0.0.244 → 0.0.246
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/src/index.d.ts +20 -0
- package/dist/android/src/index.js +5 -0
- package/dist/android/templates/CraftNativeBackground.kt.template +36 -2
- package/dist/android/templates/CraftNativeHaptics.kt.template +124 -0
- package/dist/android/templates/CraftNativeShell.kt.template +282 -72
- package/dist/android/templates/MainActivityNative.kt.template +8 -7
- package/dist/api/mobile.d.ts +71 -3
- package/dist/browser/storage.d.ts +11 -0
- package/dist/browser.d.ts +9 -0
- package/dist/browser.js +66 -13
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +32 -1
- package/dist/ios/src/index.js +6 -0
- package/dist/ios/templates/CraftApp.swift +8 -0
- package/dist/ios/templates/CraftHybrid.swift +192 -16
- package/dist/ios/templates/CraftNativeActions.swift +63 -5
- package/dist/ios/templates/CraftNativeFlatList.swift +13 -6
- package/dist/ios/templates/CraftNativeScreen.swift +396 -66
- package/dist/ios/templates/CraftWebAudio.swift +21 -13
- package/dist/mobile-testing.js +9 -3
- package/dist/mobile.js +33 -3
- package/package.json +1 -1
|
@@ -418,6 +418,13 @@ internal class CraftNativeStores private constructor(context: Context, private v
|
|
|
418
418
|
* to the host in this isolate (a message port or the console).
|
|
419
419
|
*/
|
|
420
420
|
internal object CraftNativePrelude {
|
|
421
|
+
/**
|
|
422
|
+
* The secure-storage key the signed-in account's token lives under
|
|
423
|
+
* (`sessionTokenKey` in craft.config.json, `auth.token` by default):
|
|
424
|
+
* `craft.session` follows it, and a change of it is `SESSION_CHANGED`.
|
|
425
|
+
*/
|
|
426
|
+
fun sessionTokenKey(config: JSONObject): String = config.optString("sessionTokenKey").ifBlank { "auth.token" }
|
|
427
|
+
|
|
421
428
|
fun script(
|
|
422
429
|
bridgeFields: String,
|
|
423
430
|
post: String,
|
|
@@ -509,6 +516,7 @@ internal object CraftNativePrelude {
|
|
|
509
516
|
var payload = message.payload || {};
|
|
510
517
|
var bucket = sync[payload.store];
|
|
511
518
|
if (!bucket) return;
|
|
519
|
+
var sessionBefore = payload.store === 'secure' ? sessionToken() : null;
|
|
512
520
|
if (payload.clear) {
|
|
513
521
|
Object.keys(bucket).forEach(function(key) {
|
|
514
522
|
if (!(payload.keep || []).includes(key)) {
|
|
@@ -516,11 +524,13 @@ internal object CraftNativePrelude {
|
|
|
516
524
|
if (payload.store === 'snapshots') emit({ type: 'SNAPSHOT_CHANGED', payload: { name: key } });
|
|
517
525
|
}
|
|
518
526
|
});
|
|
527
|
+
if (payload.store === 'secure') sessionMoved(sessionBefore);
|
|
519
528
|
return;
|
|
520
529
|
}
|
|
521
530
|
if (payload.value === null || payload.value === undefined) delete bucket[payload.key];
|
|
522
531
|
else bucket[payload.key] = payload.value;
|
|
523
532
|
if (payload.store === 'snapshots') emit({ type: 'SNAPSHOT_CHANGED', payload: { name: payload.key } });
|
|
533
|
+
if (payload.store === 'secure') sessionMoved(sessionBefore);
|
|
524
534
|
return;
|
|
525
535
|
}
|
|
526
536
|
if (message && message.type === 'TIMER_FIRE') {
|
|
@@ -551,6 +561,29 @@ internal object CraftNativePrelude {
|
|
|
551
561
|
}
|
|
552
562
|
emit(raw);
|
|
553
563
|
}
|
|
564
|
+
// The signed-in account: the token under `sessionTokenKey` in the
|
|
565
|
+
// secure store. Every isolate hears every secure write (SYNC_UPDATE,
|
|
566
|
+
// the writer's too), so a change of the token, whoever made it, is
|
|
567
|
+
// one `SESSION_CHANGED { key, token, previous }` here, after the
|
|
568
|
+
// store holds it, then the `craft.session.onChange` listeners.
|
|
569
|
+
var sessionListeners = [];
|
|
570
|
+
function sessionKey() {
|
|
571
|
+
var bridge = globalThis.__stxNativeBridge;
|
|
572
|
+
return bridge && typeof bridge.sessionTokenKey === 'string' && bridge.sessionTokenKey ? bridge.sessionTokenKey : 'auth.token';
|
|
573
|
+
}
|
|
574
|
+
function sessionToken() {
|
|
575
|
+
var value = sync.secure[sessionKey()];
|
|
576
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
577
|
+
}
|
|
578
|
+
function sessionMoved(before) {
|
|
579
|
+
var token = sessionToken();
|
|
580
|
+
if (token === before) return;
|
|
581
|
+
var change = { key: sessionKey(), token: token, previous: before };
|
|
582
|
+
emit({ type: 'SESSION_CHANGED', payload: change });
|
|
583
|
+
sessionListeners.slice().forEach(function(callback) {
|
|
584
|
+
try { callback(token, before); } catch (error) { console.error('[session] ' + (error && error.stack || error)); }
|
|
585
|
+
});
|
|
586
|
+
}
|
|
554
587
|
function wait(type, payload) {
|
|
555
588
|
return new Promise(function(resolve, reject) {
|
|
556
589
|
var id = 'sync-' + (++nextWait);
|
|
@@ -623,6 +656,18 @@ internal object CraftNativePrelude {
|
|
|
623
656
|
return value === undefined ? null : value;
|
|
624
657
|
}
|
|
625
658
|
});
|
|
659
|
+
keep('session', {
|
|
660
|
+
key: sessionKey(),
|
|
661
|
+
token: sessionToken,
|
|
662
|
+
onChange: function(callback) {
|
|
663
|
+
if (typeof callback !== 'function') throw new TypeError('craft.session.onChange needs a function');
|
|
664
|
+
sessionListeners.push(callback);
|
|
665
|
+
return function() {
|
|
666
|
+
var index = sessionListeners.indexOf(callback);
|
|
667
|
+
if (index >= 0) sessionListeners.splice(index, 1);
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
});
|
|
626
671
|
// Older native bundles call the page-shaped `craft.dialog` API
|
|
627
672
|
// directly. Keep that API on the capability bridge so JavaScript
|
|
628
673
|
// Core/JavaScriptSandbox screens never fall back to a DOM sheet.
|
|
@@ -1146,91 +1191,155 @@ internal object CraftNativeSymbols {
|
|
|
1146
1191
|
* Video and audio kept on the phone for offline (`craft.media`), the iOS
|
|
1147
1192
|
* host's CraftNativeMedia: `files/craft-media/<sha256 of the address>.<ext>`.
|
|
1148
1193
|
* A native Video given an address with a copy here plays the copy.
|
|
1194
|
+
*
|
|
1195
|
+
* Each download is a WorkManager job named by its address
|
|
1196
|
+
* (CraftNativeMediaWorker), so it carries on when Android stops the app and
|
|
1197
|
+
* runs when its network is there: any connection, or an unmetered one for a
|
|
1198
|
+
* Wi-Fi-only keep. A part file is kept between attempts and resumed with a
|
|
1199
|
+
* Range request. A keep the phone cannot fetch now (no connection, or only
|
|
1200
|
+
* a metered one for a Wi-Fi-only keep) answers at once with `{ ok: false,
|
|
1201
|
+
* reason: "offline" | "cellular", queued: true }`, as iOS does, and the
|
|
1202
|
+
* transfer finishes on its own; the next keep answers from the copy.
|
|
1149
1203
|
*/
|
|
1150
1204
|
internal object CraftNativeMedia {
|
|
1151
|
-
private
|
|
1152
|
-
private val
|
|
1153
|
-
private val
|
|
1205
|
+
private const val TAG = "craft-media"
|
|
1206
|
+
private const val PREFS = "craft.media"
|
|
1207
|
+
private val lock = Any()
|
|
1208
|
+
/** Keeps waiting in this process for an address's transfer. */
|
|
1209
|
+
private val waiting = HashMap<String, MutableList<(CraftNativeCapabilityResult) -> Unit>>()
|
|
1210
|
+
/** One transfer writes an address's part file at a time. */
|
|
1211
|
+
private val writers = ConcurrentHashMap<String, Any>()
|
|
1212
|
+
private val background = Executors.newSingleThreadExecutor()
|
|
1154
1213
|
private val main = Handler(Looper.getMainLooper())
|
|
1155
1214
|
|
|
1156
1215
|
private fun root(context: Context) = File(context.applicationContext.filesDir, "craft-media")
|
|
1157
1216
|
|
|
1217
|
+
fun digest(address: String): String =
|
|
1218
|
+
java.security.MessageDigest.getInstance("SHA-256").digest(address.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
1219
|
+
|
|
1158
1220
|
fun file(context: Context, address: String): File {
|
|
1159
|
-
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(address.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
1160
1221
|
val ext = runCatching { Uri.parse(address).lastPathSegment?.substringAfterLast('.', "")?.lowercase() }.getOrNull().orEmpty()
|
|
1161
1222
|
val safe = if (Regex("^[a-z0-9]{1,5}$").matches(ext)) ext else "bin"
|
|
1162
|
-
return File(root(context), "$digest.$safe")
|
|
1223
|
+
return File(root(context), "${digest(address)}.$safe")
|
|
1163
1224
|
}
|
|
1164
1225
|
|
|
1226
|
+
/** Where an address's transfer writes until the file is whole; kept between attempts to resume. */
|
|
1227
|
+
fun part(context: Context, address: String): File = File(root(context), "${file(context, address).name}.part")
|
|
1228
|
+
|
|
1165
1229
|
fun local(context: Context, address: String): File? =
|
|
1166
1230
|
address.takeIf { it.isNotBlank() }?.let { file(context, it) }?.takeIf { it.isFile && it.length() > 0 }
|
|
1167
1231
|
|
|
1168
1232
|
private fun describe(address: String, file: File) = JSONObject().put("ok", true).put("url", address).put("path", file.absolutePath).put("size", file.length())
|
|
1169
1233
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1234
|
+
private fun prefs(context: Context): SharedPreferences = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
1235
|
+
|
|
1236
|
+
/** How many times the cache was cleared; a transfer started before the last clear does not land. */
|
|
1237
|
+
fun generation(context: Context): Int = prefs(context).getInt("generation", 0)
|
|
1238
|
+
|
|
1239
|
+
/** The unique job for an address and connection rule: `any` or `wifi`. */
|
|
1240
|
+
private fun workName(address: String, wifiOnly: Boolean) = "$TAG:${digest(address)}:${if (wifiOnly) "wifi" else "any"}"
|
|
1241
|
+
|
|
1176
1242
|
fun keep(context: Context, address: String, headers: JSONObject, cellular: Boolean, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
1177
1243
|
val uri = runCatching { Uri.parse(address) }.getOrNull()
|
|
1178
1244
|
if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https") || uri.host.isNullOrBlank()) {
|
|
1179
1245
|
return done(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "media.keep needs an absolute http or https URL"))
|
|
1180
1246
|
}
|
|
1181
1247
|
local(context, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
|
|
1182
|
-
|
|
1183
|
-
|
|
1248
|
+
val app = context.applicationContext
|
|
1249
|
+
synchronized(lock) { waiting.getOrPut(address) { mutableListOf() }.add(done) }
|
|
1250
|
+
val wifiOnly = !cellular
|
|
1251
|
+
val work = androidx.work.WorkManager.getInstance(app)
|
|
1252
|
+
// The same address under the other rule gives way; its part file is resumed.
|
|
1253
|
+
work.cancelUniqueWork(workName(address, !wifiOnly))
|
|
1254
|
+
val request = androidx.work.OneTimeWorkRequestBuilder<CraftNativeMediaWorker>()
|
|
1255
|
+
.setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(if (wifiOnly) androidx.work.NetworkType.UNMETERED else androidx.work.NetworkType.CONNECTED).build())
|
|
1256
|
+
.setInputData(androidx.work.workDataOf("address" to address, "headers" to headers.toString(), "generation" to generation(app)))
|
|
1257
|
+
.setBackoffCriteria(androidx.work.BackoffPolicy.EXPONENTIAL, 30, java.util.concurrent.TimeUnit.SECONDS)
|
|
1258
|
+
.addTag(TAG)
|
|
1259
|
+
.addTag("$TAG:${digest(address)}")
|
|
1260
|
+
.build()
|
|
1261
|
+
// KEEP: a transfer already queued or running for this address is joined, not started again.
|
|
1262
|
+
work.enqueueUniqueWork(workName(address, wifiOnly), androidx.work.ExistingWorkPolicy.KEEP, request)
|
|
1263
|
+
val reason = when {
|
|
1264
|
+
!online(app) -> "offline"
|
|
1265
|
+
wifiOnly && metered(app) -> "cellular"
|
|
1266
|
+
else -> null
|
|
1184
1267
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1268
|
+
if (reason != null) deliver(address, queued(address, reason))
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
private fun queued(address: String, reason: String) =
|
|
1272
|
+
CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", reason).put("queued", true))
|
|
1273
|
+
|
|
1274
|
+
/** Answers every keep waiting for `address`. */
|
|
1275
|
+
fun deliver(address: String, result: CraftNativeCapabilityResult) {
|
|
1276
|
+
val answered = synchronized(lock) { waiting.remove(address).orEmpty() }
|
|
1277
|
+
if (answered.isNotEmpty()) main.post { answered.forEach { it(result) } }
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
enum class Outcome { DONE, RETRY }
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* One attempt at an address's transfer, run by its job: resumes the part
|
|
1284
|
+
* file where it stopped, moves it into place whole, and answers whoever
|
|
1285
|
+
* waits. RETRY leaves the part file for the next attempt.
|
|
1286
|
+
*/
|
|
1287
|
+
fun transfer(context: Context, address: String, headers: JSONObject, started: Int, attempt: Int, stopped: () -> Boolean): Outcome {
|
|
1288
|
+
if (generation(context) != started) {
|
|
1289
|
+
deliver(address, CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared"))
|
|
1290
|
+
return Outcome.DONE
|
|
1191
1291
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
try {
|
|
1206
|
-
val size = connection.inputStream.use { input -> staging.outputStream().use { input.copyTo(it) } }
|
|
1207
|
-
val expected = connection.contentLengthLong
|
|
1208
|
-
if (size <= 0 || (expected >= 0 && size != expected)) {
|
|
1209
|
-
return@runCatching CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file")
|
|
1210
|
-
}
|
|
1211
|
-
synchronized(inFlight) {
|
|
1212
|
-
if (inFlight[address]?.id != flight.id) {
|
|
1213
|
-
return@runCatching CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")
|
|
1214
|
-
}
|
|
1215
|
-
if (!staging.renameTo(target)) {
|
|
1216
|
-
return@runCatching CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file")
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
} finally {
|
|
1220
|
-
staging.delete()
|
|
1221
|
-
}
|
|
1222
|
-
CraftNativeCapabilityResult(data = describe(address, target))
|
|
1223
|
-
} finally {
|
|
1224
|
-
connection.disconnect()
|
|
1225
|
-
}
|
|
1226
|
-
}.getOrElse { error ->
|
|
1227
|
-
val full = generateSequence(error) { it.cause }.take(16).any {
|
|
1292
|
+
local(context, address)?.let {
|
|
1293
|
+
deliver(address, CraftNativeCapabilityResult(data = describe(address, it)))
|
|
1294
|
+
return Outcome.DONE
|
|
1295
|
+
}
|
|
1296
|
+
val writer = writers.getOrPut(address) { Any() }
|
|
1297
|
+
synchronized(writer) {
|
|
1298
|
+
val target = file(context, address)
|
|
1299
|
+
val staging = part(context, address)
|
|
1300
|
+
target.parentFile?.mkdirs()
|
|
1301
|
+
val fetched = try {
|
|
1302
|
+
CraftResumableDownload().fetch(address, headers.keys().asSequence().associateWith { headers.optString(it) }, staging, stopped)
|
|
1303
|
+
} catch (error: Exception) {
|
|
1304
|
+
val full = generateSequence<Throwable>(error) { it.cause }.take(16).any {
|
|
1228
1305
|
it is android.system.ErrnoException && it.errno == android.system.OsConstants.ENOSPC
|
|
1229
1306
|
}
|
|
1230
|
-
|
|
1307
|
+
if (full) {
|
|
1308
|
+
staging.delete()
|
|
1309
|
+
deliver(address, CraftNativeCapabilityResult(code = "NO_SPACE", message = error.message ?: "The phone is out of space"))
|
|
1310
|
+
return Outcome.DONE
|
|
1311
|
+
}
|
|
1312
|
+
if (stopped()) return Outcome.RETRY
|
|
1313
|
+
// Lost the connection part way: the part waits for the next attempt.
|
|
1314
|
+
deliver(address, if (!online(context)) queued(address, "offline") else CraftNativeCapabilityResult(code = if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed"))
|
|
1315
|
+
return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
|
|
1316
|
+
}
|
|
1317
|
+
when (fetched) {
|
|
1318
|
+
is CraftResumableDownload.Fetched.Stopped, is CraftResumableDownload.Fetched.Restart -> return Outcome.RETRY
|
|
1319
|
+
is CraftResumableDownload.Fetched.Refused -> {
|
|
1320
|
+
deliver(address, CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", fetched.status)))
|
|
1321
|
+
return Outcome.DONE
|
|
1322
|
+
}
|
|
1323
|
+
is CraftResumableDownload.Fetched.Incomplete -> {
|
|
1324
|
+
deliver(address, CraftNativeCapabilityResult(code = "NETWORK_ERROR", message = "media.keep received an incomplete file"))
|
|
1325
|
+
return if (attempt < 8) Outcome.RETRY else Outcome.DONE.also { staging.delete() }
|
|
1326
|
+
}
|
|
1327
|
+
is CraftResumableDownload.Fetched.Complete -> Unit
|
|
1231
1328
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1329
|
+
synchronized(lock) {
|
|
1330
|
+
if (generation(context) != started) {
|
|
1331
|
+
staging.delete()
|
|
1332
|
+
deliver(address, CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared"))
|
|
1333
|
+
return Outcome.DONE
|
|
1334
|
+
}
|
|
1335
|
+
target.delete()
|
|
1336
|
+
if (!staging.renameTo(target)) {
|
|
1337
|
+
deliver(address, CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file"))
|
|
1338
|
+
return Outcome.DONE
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
deliver(address, CraftNativeCapabilityResult(data = describe(address, target)))
|
|
1342
|
+
return Outcome.DONE
|
|
1234
1343
|
}
|
|
1235
1344
|
}
|
|
1236
1345
|
|
|
@@ -1240,36 +1349,55 @@ internal object CraftNativeMedia {
|
|
|
1240
1349
|
connectivity.isActiveNetworkMetered
|
|
1241
1350
|
}.getOrDefault(false)
|
|
1242
1351
|
|
|
1243
|
-
/**
|
|
1352
|
+
/** Whether there is a connection to the internet at all. */
|
|
1353
|
+
private fun online(context: Context): Boolean = runCatching {
|
|
1354
|
+
val connectivity = context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
|
1355
|
+
val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork)
|
|
1356
|
+
capabilities?.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
|
1357
|
+
}.getOrDefault(true)
|
|
1358
|
+
|
|
1359
|
+
/** Removes every copy whose address is not in `keep`, and stops their transfers; answers how many files went. */
|
|
1244
1360
|
fun prune(context: Context, keep: List<String>): Int {
|
|
1245
|
-
val wanted = keep.map {
|
|
1361
|
+
val wanted = keep.map { digest(it) }.toSet()
|
|
1246
1362
|
var removed = 0
|
|
1247
|
-
synchronized(
|
|
1248
|
-
// A
|
|
1249
|
-
val active =
|
|
1250
|
-
val name = file(context, address).name
|
|
1251
|
-
listOf(name, "$name.${flight.id}.part")
|
|
1252
|
-
}.toSet()
|
|
1363
|
+
synchronized(lock) {
|
|
1364
|
+
// A keep still waiting owns its address's files until it is answered.
|
|
1365
|
+
val active = waiting.keys.map { digest(it) }.toSet()
|
|
1253
1366
|
root(context).listFiles()?.forEach { file ->
|
|
1254
|
-
|
|
1367
|
+
val owner = file.name.substringBefore('.')
|
|
1368
|
+
// Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
|
|
1369
|
+
val stale = file.name.endsWith(".part") && file.name.count { it == '.' } > 2
|
|
1370
|
+
if (owner !in active && (owner !in wanted || stale) && file.delete()) removed++
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
val app = context.applicationContext
|
|
1374
|
+
background.execute {
|
|
1375
|
+
runCatching {
|
|
1376
|
+
androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get()
|
|
1377
|
+
.filter { !it.state.isFinished }
|
|
1378
|
+
.filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:") in wanted } }
|
|
1379
|
+
.forEach { androidx.work.WorkManager.getInstance(app).cancelWorkById(it.id) }
|
|
1255
1380
|
}
|
|
1256
1381
|
}
|
|
1257
1382
|
return removed
|
|
1258
1383
|
}
|
|
1259
1384
|
|
|
1260
|
-
/**
|
|
1385
|
+
/** Stops every transfer and deletes every copy; none started before this lands after it. */
|
|
1261
1386
|
fun clear(context: Context): Int {
|
|
1262
1387
|
var removed = 0
|
|
1263
1388
|
var failure: java.io.IOException? = null
|
|
1264
|
-
val
|
|
1265
|
-
|
|
1266
|
-
|
|
1389
|
+
val answered = synchronized(lock) {
|
|
1390
|
+
// Persisted before the files go: a transfer that outlives the process checks it too.
|
|
1391
|
+
prefs(context).edit().putInt("generation", generation(context) + 1).commit()
|
|
1392
|
+
val callbacks = waiting.values.flatten()
|
|
1393
|
+
waiting.clear()
|
|
1267
1394
|
root(context).listFiles()?.forEach { file ->
|
|
1268
1395
|
if (file.delete()) removed++ else failure = java.io.IOException("The video cache could not be cleared")
|
|
1269
1396
|
}
|
|
1270
1397
|
callbacks
|
|
1271
1398
|
}
|
|
1272
|
-
|
|
1399
|
+
androidx.work.WorkManager.getInstance(context.applicationContext).cancelAllWorkByTag(TAG)
|
|
1400
|
+
main.post { answered.forEach { it(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
|
|
1273
1401
|
failure?.let { throw it }
|
|
1274
1402
|
return removed
|
|
1275
1403
|
}
|
|
@@ -1280,6 +1408,88 @@ internal object CraftNativeMedia {
|
|
|
1280
1408
|
}
|
|
1281
1409
|
}
|
|
1282
1410
|
|
|
1411
|
+
/**
|
|
1412
|
+
* One attempt at fetching a file into `part`, resuming what an earlier
|
|
1413
|
+
* attempt left there with a Range request. Plain Java I/O, so the JVM tests
|
|
1414
|
+
* run it against a local server. A part that no longer lines up with the
|
|
1415
|
+
* file (416, or a range that starts elsewhere) is dropped and the attempt
|
|
1416
|
+
* says Restart; a server that ignores Range sends the whole file again.
|
|
1417
|
+
*/
|
|
1418
|
+
internal class CraftResumableDownload(
|
|
1419
|
+
private val open: (String) -> java.net.HttpURLConnection = { java.net.URL(it).openConnection() as java.net.HttpURLConnection },
|
|
1420
|
+
) {
|
|
1421
|
+
sealed class Fetched {
|
|
1422
|
+
data class Complete(val size: Long) : Fetched()
|
|
1423
|
+
data class Refused(val status: Int) : Fetched()
|
|
1424
|
+
object Incomplete : Fetched()
|
|
1425
|
+
object Restart : Fetched()
|
|
1426
|
+
object Stopped : Fetched()
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
fun fetch(address: String, headers: Map<String, String>, part: File, stopped: () -> Boolean): Fetched {
|
|
1430
|
+
val have = if (part.isFile) part.length() else 0L
|
|
1431
|
+
val connection = open(address)
|
|
1432
|
+
connection.connectTimeout = 30_000
|
|
1433
|
+
connection.readTimeout = 60_000
|
|
1434
|
+
headers.forEach { (name, value) -> connection.setRequestProperty(name, value) }
|
|
1435
|
+
if (have > 0) connection.setRequestProperty("Range", "bytes=$have-")
|
|
1436
|
+
try {
|
|
1437
|
+
val status = connection.responseCode
|
|
1438
|
+
val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have
|
|
1439
|
+
if (status == 416 || (status == 206 && !resumed)) {
|
|
1440
|
+
part.delete()
|
|
1441
|
+
return Fetched.Restart
|
|
1442
|
+
}
|
|
1443
|
+
if (status != 200 && !resumed) {
|
|
1444
|
+
part.delete()
|
|
1445
|
+
return Fetched.Refused(status)
|
|
1446
|
+
}
|
|
1447
|
+
val total = if (resumed) rangeTotal(connection.getHeaderField("Content-Range")) else connection.contentLengthLong
|
|
1448
|
+
part.parentFile?.mkdirs()
|
|
1449
|
+
connection.inputStream.use { input ->
|
|
1450
|
+
java.io.FileOutputStream(part, resumed).use { output ->
|
|
1451
|
+
val buffer = ByteArray(64 * 1024)
|
|
1452
|
+
while (true) {
|
|
1453
|
+
if (stopped()) return Fetched.Stopped
|
|
1454
|
+
val read = input.read(buffer)
|
|
1455
|
+
if (read < 0) break
|
|
1456
|
+
output.write(buffer, 0, read)
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
val size = part.length()
|
|
1461
|
+
// HttpURLConnection inflates gzip itself; a Content-Length is then the compressed size.
|
|
1462
|
+
val encoded = connection.getHeaderField("Content-Encoding") != null
|
|
1463
|
+
if (size <= 0 || (!encoded && total >= 0 && size != total)) {
|
|
1464
|
+
// Longer than the file says it is: nothing to resume from.
|
|
1465
|
+
if (total in 0 until size) part.delete()
|
|
1466
|
+
return Fetched.Incomplete
|
|
1467
|
+
}
|
|
1468
|
+
return Fetched.Complete(size)
|
|
1469
|
+
} finally {
|
|
1470
|
+
connection.disconnect()
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
companion object {
|
|
1475
|
+
/** `bytes 100-199/1000` → 100; -1 when it does not say. */
|
|
1476
|
+
fun rangeStart(header: String?): Long = Regex("^bytes (\\d+)-\\d+/(\\d+|\\*)$").find(header?.trim().orEmpty())?.groupValues?.get(1)?.toLongOrNull() ?: -1L
|
|
1477
|
+
|
|
1478
|
+
/** `bytes 100-199/1000` → 1000; -1 when the total is not known. */
|
|
1479
|
+
fun rangeTotal(header: String?): Long = Regex("^bytes \\d+-\\d+/(\\d+)$").find(header?.trim().orEmpty())?.groupValues?.get(1)?.toLongOrNull() ?: -1L
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** One `craft.media.keep` transfer, run by WorkManager when its network is there (CraftNativeMedia). */
|
|
1484
|
+
internal class CraftNativeMediaWorker(context: Context, parameters: androidx.work.WorkerParameters) : androidx.work.Worker(context, parameters) {
|
|
1485
|
+
override fun doWork(): Result {
|
|
1486
|
+
val address = inputData.getString("address") ?: return Result.failure()
|
|
1487
|
+
val headers = runCatching { JSONObject(inputData.getString("headers") ?: "{}") }.getOrDefault(JSONObject())
|
|
1488
|
+
val outcome = CraftNativeMedia.transfer(applicationContext, address, headers, inputData.getInt("generation", 0), runAttemptCount) { isStopped }
|
|
1489
|
+
return if (outcome == CraftNativeMedia.Outcome.RETRY) Result.retry() else Result.success()
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1283
1493
|
/**
|
|
1284
1494
|
* Messages from an isolate on the console bridge (a WebView without
|
|
1285
1495
|
* JavaScriptSandbox's message ports): the console cuts a message at about
|
|
@@ -40,8 +40,6 @@ import android.os.Build
|
|
|
40
40
|
import android.os.Bundle
|
|
41
41
|
import android.os.Handler
|
|
42
42
|
import android.os.Looper
|
|
43
|
-
import android.os.VibrationEffect
|
|
44
|
-
import android.os.Vibrator
|
|
45
43
|
import android.provider.Settings
|
|
46
44
|
import android.text.Editable
|
|
47
45
|
import android.text.InputFilter
|
|
@@ -4330,7 +4328,8 @@ class MainActivity : AppCompatActivity() {
|
|
|
4330
4328
|
appearance: ${appearance()},
|
|
4331
4329
|
appURL: ${JSONObject.quote(config.optString("devServerURL"))},
|
|
4332
4330
|
appVersion: ${JSONObject.quote(appVersion().first)},
|
|
4333
|
-
appBuild: ${JSONObject.quote(appVersion().second)}
|
|
4331
|
+
appBuild: ${JSONObject.quote(appVersion().second)},
|
|
4332
|
+
sessionTokenKey: ${JSONObject.quote(CraftNativePrelude.sessionTokenKey(config))}
|
|
4334
4333
|
""".trimIndent()
|
|
4335
4334
|
// The stores can run to megabytes: read and laid out off the main
|
|
4336
4335
|
// thread, then handed to the isolate.
|
|
@@ -6801,12 +6800,14 @@ class MainActivity : AppCompatActivity() {
|
|
|
6801
6800
|
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
6802
6801
|
CraftNativeCapabilityResult(data = clipboard.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() ?: "")
|
|
6803
6802
|
}
|
|
6804
|
-
|
|
6803
|
+
// The iOS names (CraftNativeActions.swift): an impact weight, a
|
|
6804
|
+
// notification type or selection, through CraftNativeHaptics.
|
|
6805
|
+
// `prepare` warms nothing here, so it answers true at once.
|
|
6806
|
+
"Haptics" to "impact", "Haptics" to "notification", "Haptics" to "selection", "Haptics" to "prepare" -> if (!hapticsEnabled) {
|
|
6805
6807
|
CraftNativeCapabilityResult(code = "CAPABILITY_DISABLED", message = "Haptics are disabled")
|
|
6806
6808
|
} else {
|
|
6807
|
-
val
|
|
6808
|
-
|
|
6809
|
-
CraftNativeCapabilityResult(data = true)
|
|
6809
|
+
val style = CraftNativeHaptics.style(method, args.opt(0) as? String)
|
|
6810
|
+
CraftNativeCapabilityResult(data = style == null || CraftNativeHaptics.play(this, window?.decorView, style))
|
|
6810
6811
|
}
|
|
6811
6812
|
else -> CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported native API")
|
|
6812
6813
|
}
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -455,6 +455,56 @@ export declare const secureStorage: {
|
|
|
455
455
|
*/
|
|
456
456
|
clear(): Promise<void>;
|
|
457
457
|
};
|
|
458
|
+
/** `craft.session.onChange`'s listener: the token now, and the one before (null when signed out). */
|
|
459
|
+
export type SessionListener = (token: string | null, previous: string | null) => void;
|
|
460
|
+
/**
|
|
461
|
+
* One change of the signed-in account, as the hosts report it: the
|
|
462
|
+
* `SESSION_CHANGED` message a native screen hears, and the detail of the
|
|
463
|
+
* page's `craftsession` event. `key` is the secure-storage name of the
|
|
464
|
+
* token (`sessionTokenKey` in the Craft config, `auth.token` by default).
|
|
465
|
+
*/
|
|
466
|
+
export interface SessionChange {
|
|
467
|
+
key: string;
|
|
468
|
+
token: string | null;
|
|
469
|
+
previous: string | null;
|
|
470
|
+
}
|
|
471
|
+
/** The page event a host dispatches when the signed-in account changes. */
|
|
472
|
+
export declare const SESSION_EVENT = "craftsession";
|
|
473
|
+
/** The bridge message a native screen's runtime hears when the signed-in account changes. */
|
|
474
|
+
export declare const SESSION_MESSAGE = "SESSION_CHANGED";
|
|
475
|
+
/** The default secure-storage name of the sign-in token. */
|
|
476
|
+
export declare const DEFAULT_SESSION_TOKEN_KEY = "auth.token";
|
|
477
|
+
/**
|
|
478
|
+
* The signed-in account, as the app's sign-in token in secure storage
|
|
479
|
+
* (`sessionTokenKey`, default `auth.token`). Every host reports a change of
|
|
480
|
+
* it, whoever made it: a native screen's `secureStorage.set`, the web page's
|
|
481
|
+
* own sign-in (a `shareStorage` key), a sign-out, or another browser tab.
|
|
482
|
+
* Use it to start a screen afresh for another account instead of comparing
|
|
483
|
+
* snapshots on every change.
|
|
484
|
+
*
|
|
485
|
+
* @example
|
|
486
|
+
* ```typescript
|
|
487
|
+
* import { session } from 'craft-native/mobile'
|
|
488
|
+
*
|
|
489
|
+
* let account = session.token()
|
|
490
|
+
* const stop = session.onChange((token, previous) => {
|
|
491
|
+
* if (!token) return showSignIn()
|
|
492
|
+
* if (previous && token !== previous) resetScreen()
|
|
493
|
+
* account = token
|
|
494
|
+
* })
|
|
495
|
+
* ```
|
|
496
|
+
*/
|
|
497
|
+
export declare const session: {
|
|
498
|
+
/** The sign-in token now, or null when signed out or the host keeps none. */
|
|
499
|
+
token(): string | null;
|
|
500
|
+
/**
|
|
501
|
+
* Hear each change of the sign-in token, with the new token and the one
|
|
502
|
+
* before it. Fires once per change, after storage holds the new value, on
|
|
503
|
+
* every screen and the page (including the one that wrote it). Returns the
|
|
504
|
+
* unsubscribe function.
|
|
505
|
+
*/
|
|
506
|
+
onChange(callback: SessionListener): () => void;
|
|
507
|
+
};
|
|
458
508
|
/**
|
|
459
509
|
* Location coordinates.
|
|
460
510
|
*/
|
|
@@ -1404,11 +1454,28 @@ export declare const refresh: {
|
|
|
1404
1454
|
onRefresh(callback: () => void): () => void;
|
|
1405
1455
|
};
|
|
1406
1456
|
/**
|
|
1407
|
-
* Background time
|
|
1408
|
-
* scheduled refresh (`backgroundRefresh.enabled`
|
|
1409
|
-
*
|
|
1457
|
+
* Background time: a silent push (`content-available` on iOS, an FCM data
|
|
1458
|
+
* message on Android) and the scheduled refresh (`backgroundRefresh.enabled`
|
|
1459
|
+
* in the Craft config). Each event's detail carries an `id`; answer that one
|
|
1460
|
+
* with `complete(ok, id)` once its work is done. iOS ends a task the page has
|
|
1461
|
+
* not answered after 25 seconds.
|
|
1462
|
+
*
|
|
1463
|
+
* @example
|
|
1464
|
+
* ```typescript
|
|
1465
|
+
* background.onSilentPush(async ({ id, payload }) => {
|
|
1466
|
+
* const ok = await sync(payload).then(() => true, () => false)
|
|
1467
|
+
* await background.complete(ok, id)
|
|
1468
|
+
* })
|
|
1469
|
+
* ```
|
|
1410
1470
|
*/
|
|
1411
1471
|
export declare const background: {
|
|
1472
|
+
/**
|
|
1473
|
+
* Finish background work: `ok` is whether it succeeded (iOS reports it to
|
|
1474
|
+
* the system as the task's result). Pass the event's `id` to finish that
|
|
1475
|
+
* task alone; without one, every pending task finishes, which also ends a
|
|
1476
|
+
* refresh that is still working when a push's handler settles. Resolves
|
|
1477
|
+
* whether the host took the answer (false off a Craft shell).
|
|
1478
|
+
*/
|
|
1412
1479
|
complete(ok?: boolean, id?: string): Promise<boolean>;
|
|
1413
1480
|
onRefresh(callback: (event: BackgroundWorkEvent) => void): () => void;
|
|
1414
1481
|
onSilentPush(callback: (event: BackgroundWorkEvent) => void): () => void;
|
|
@@ -1756,6 +1823,7 @@ declare const mobile: {
|
|
|
1756
1823
|
chrome: typeof chrome;
|
|
1757
1824
|
refresh: typeof refresh;
|
|
1758
1825
|
background: typeof background;
|
|
1826
|
+
session: typeof session;
|
|
1759
1827
|
appearance: typeof appearance;
|
|
1760
1828
|
};
|
|
1761
1829
|
export default mobile;
|
|
@@ -2,10 +2,21 @@ export interface BrowserStorageOptions {
|
|
|
2
2
|
storage: Storage;
|
|
3
3
|
namespace: string;
|
|
4
4
|
tokenKey: string;
|
|
5
|
+
/**
|
|
6
|
+
* The secure-storage name screens read the sign-in token under
|
|
7
|
+
* (`craft.secureStorage.getSync('auth.token')`), stored at `tokenKey`.
|
|
8
|
+
* Default `auth.token`.
|
|
9
|
+
*/
|
|
10
|
+
sessionTokenKey?: string;
|
|
5
11
|
/** Existing website identity keys to forget whenever its token changes. */
|
|
6
12
|
accountKeys?: string[];
|
|
7
13
|
hash: (value: string) => Promise<string>;
|
|
8
14
|
changed: (name: string) => void;
|
|
15
|
+
/**
|
|
16
|
+
* The sign-in token changed (a sign-in, a sign-out, another account), here
|
|
17
|
+
* or in another tab: called once the new account's storage is in place.
|
|
18
|
+
*/
|
|
19
|
+
sessionChanged?: (token: string | null, previous: string | null) => void;
|
|
9
20
|
/** A host lock primitive; browsers use their native Web Locks implementation. */
|
|
10
21
|
lock?: (<T>(name: string, operation: () => T | Promise<T>) => Promise<T>) | null;
|
|
11
22
|
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export interface BrowserPlatformOptions {
|
|
2
2
|
namespace?: string;
|
|
3
|
+
/** The localStorage key the website keeps its sign-in token under. Default `craft.auth.token`. */
|
|
3
4
|
tokenKey?: string;
|
|
5
|
+
/**
|
|
6
|
+
* The secure-storage name screens read that token by
|
|
7
|
+
* (`craft.secureStorage.getSync('auth.token')`), and the key
|
|
8
|
+
* `craft.session` follows. Default `auth.token`.
|
|
9
|
+
*/
|
|
10
|
+
sessionTokenKey?: string;
|
|
4
11
|
accountKeys?: string[];
|
|
5
12
|
/** App metadata shared with native hosts; no device capabilities are implied. */
|
|
6
13
|
appURL?: string;
|
|
@@ -18,5 +25,7 @@ export interface BrowserPlatform {
|
|
|
18
25
|
resolveFile: (value: unknown) => unknown;
|
|
19
26
|
dispose: () => void;
|
|
20
27
|
}
|
|
28
|
+
/** Vibration API timings for a Haptics call, in the native hosts' weights. */
|
|
29
|
+
export declare function browserHapticPattern(method: string, style: unknown): number | number[] | null;
|
|
21
30
|
/** Real browser counterparts for the portable Craft services. Device-only APIs remain absent. */
|
|
22
31
|
export declare function createBrowserPlatform(options?: BrowserPlatformOptions): Promise<BrowserPlatform>;
|