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.
@@ -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 data class Flight(val id: String, val waiting: MutableList<(CraftNativeCapabilityResult) -> Unit>)
1152
- private val inFlight = HashMap<String, Flight>()
1153
- private val executor = Executors.newFixedThreadPool(2)
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
- * `cellular = false` keeps it off a metered connection (mobile data, a
1172
- * hotspot): nothing is fetched and the answer is `{ ok: false, reason:
1173
- * "cellular" }`, so the screen can say it is waiting for Wi-Fi. A copy
1174
- * already kept still answers, whatever the connection.
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
- if (!cellular && metered(context)) {
1183
- return done(CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("reason", "cellular")))
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
- val flight = synchronized(inFlight) {
1186
- inFlight[address]?.let { waiting ->
1187
- waiting.waiting.add(done)
1188
- return
1189
- }
1190
- Flight(java.util.UUID.randomUUID().toString(), mutableListOf(done)).also { inFlight[address] = it }
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
- executor.execute {
1193
- val result = runCatching {
1194
- val connection = java.net.URL(address).openConnection() as java.net.HttpURLConnection
1195
- connection.connectTimeout = 30_000
1196
- connection.readTimeout = 60_000
1197
- headers.keys().forEach { name -> connection.setRequestProperty(name, headers.optString(name)) }
1198
- try {
1199
- val status = connection.responseCode
1200
- if (status != 200) return@runCatching CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", status))
1201
- val target = file(context, address)
1202
- target.parentFile?.mkdirs()
1203
- // Written aside and moved into place, so a half-written file is never played.
1204
- val staging = File(target.parentFile, "${target.name}.${flight.id}.part")
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
- CraftNativeCapabilityResult(code = if (full) "NO_SPACE" else if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
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
- val waiting = synchronized(inFlight) { if (inFlight[address]?.id == flight.id) inFlight.remove(address)?.waiting.orEmpty() else emptyList() }
1233
- main.post { waiting.forEach { it(result) } }
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
- /** Removes every copy whose address is not in `keep`; answers how many. */
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 { file(context, it).name }.toSet()
1361
+ val wanted = keep.map { digest(it) }.toSet()
1246
1362
  var removed = 0
1247
- synchronized(inFlight) {
1248
- // A concurrent keep owns its target and staging file until it finishes.
1249
- val active = inFlight.flatMap { (address, flight) ->
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
- if (file.name !in active && file.name !in wanted && file.delete()) removed++
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
- /** Invalidates old transfers before deleting files; a later keep starts afresh. */
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 waiting = synchronized(inFlight) {
1265
- val callbacks = inFlight.values.flatMap { it.waiting }
1266
- inFlight.clear()
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
- main.post { waiting.forEach { it(CraftNativeCapabilityResult(code = "CANCELLED", message = "The video cache was cleared")) } }
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
- "Haptics" to "impact" -> if (!hapticsEnabled) {
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 vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
6808
- vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
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
  }
@@ -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 on iOS: a silent push (`content-available`) and the
1408
- * scheduled refresh (`backgroundRefresh.enabled` in the Craft config). Each
1409
- * waits for `complete(ok)` from the page, or 25 seconds.
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>;