craft-native 0.0.490 → 0.0.492

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.
@@ -294,6 +294,8 @@ internal class CraftNativeRoutes(config: JSONObject) {
294
294
  /** One process-wide boundary for committed storage reads and mutations. */
295
295
  internal object CraftNativeStorage {
296
296
  val lock = Any()
297
+ /** The secure store's writes, one at a time across every host in the process: a rollback restores what it read. */
298
+ val secureLock = Any()
297
299
  fun jsonEqual(left: Any?, right: Any?): Boolean {
298
300
  if (left == null || left === JSONObject.NULL) return right == null || right === JSONObject.NULL
299
301
  if (left is Boolean || right is Boolean) return left is Boolean && right is Boolean && left == right
@@ -365,6 +367,30 @@ internal class CraftNativeStores private constructor(
365
367
  cache
366
368
  }
367
369
 
370
+ /**
371
+ * Checkpoints that have been asked for and are not on disk yet, the
372
+ * newest per name; a null is one being deleted. A read finds these first,
373
+ * as UIKit's `CraftSnapshots.read` finds its `pending`, so a screen opened
374
+ * right after a checkpoint is seeded with it, and one opened right after
375
+ * a sign-out is not seeded with the account that just left (#507). The
376
+ * number is which request put the entry there. Guarded by itself.
377
+ */
378
+ private val pendingSnapshots = HashMap<String, Pair<Long, String?>>()
379
+ private var snapshotRevision = 0L
380
+
381
+ /** What a read gives now: the snapshots on disk with those on their way laid over them. */
382
+ private fun currentSnapshots(): Map<String, String> {
383
+ synchronized(pendingSnapshots) {
384
+ if (pendingSnapshots.isEmpty()) return snapshotCache
385
+ val current = HashMap(snapshotCache)
386
+ for ((name, waiting) in pendingSnapshots) {
387
+ val json = waiting.second
388
+ if (json == null) current.remove(name) else current[name] = json
389
+ }
390
+ return current
391
+ }
392
+ }
393
+
368
394
  /**
369
395
  * Everything an isolate needs before its first line runs, as JSON text:
370
396
  * `{ storage: { key: json }, secure: { key: value }, snapshots: { name: json } }`.
@@ -388,7 +414,7 @@ internal class CraftNativeStores private constructor(
388
414
  out.append(',')
389
415
  section("secure", runCatching { secure?.all?.mapNotNull { (key, value) -> (value as? String)?.let { key to it } }?.toMap() }.getOrNull() ?: emptyMap())
390
416
  out.append(',')
391
- section("snapshots", snapshotCache)
417
+ section("snapshots", currentSnapshots())
392
418
  out.append('}')
393
419
  return out.toString()
394
420
  }
@@ -396,7 +422,12 @@ internal class CraftNativeStores private constructor(
396
422
  /** The same, parsed (tests and small hosts). */
397
423
  fun preload(): JSONObject = JSONObject(preloadText())
398
424
 
399
- fun snapshot(name: String): String? = snapshotCache[name]
425
+ fun snapshot(name: String): String? {
426
+ synchronized(pendingSnapshots) {
427
+ val waiting = pendingSnapshots[name]
428
+ return if (waiting != null) waiting.second else snapshotCache[name]
429
+ }
430
+ }
400
431
 
401
432
  /**
402
433
  * One secure value, read directly rather than through the preload.
@@ -425,44 +456,79 @@ internal class CraftNativeStores private constructor(
425
456
  main.post { done(false) }
426
457
  return
427
458
  }
428
- writer.execute {
429
- val file = snapshotFile(name)
430
- val previous = snapshotCache[name]
431
- val ok = runCatching {
432
- if (json == null) {
433
- !file.exists() || file.delete()
434
- } else {
435
- snapshotFiles.set(file, json)
436
- true
459
+ // UIKit refuses a payload that is not JSON before it touches the
460
+ // disk, and a checkpoint kept on one host and refused on the other is
461
+ // the divergence this contract exists to prevent (#396).
462
+ if (json != null && !craftNativeIsJsonText(json)) {
463
+ main.post { done(false) }
464
+ return
465
+ }
466
+ // Admitting the write and queueing it are one step, as on UIKit:
467
+ // apart, an older request could reach the disk after a newer one
468
+ // while readers were already being given the newer value.
469
+ synchronized(pendingSnapshots) {
470
+ snapshotRevision += 1
471
+ val mine = snapshotRevision
472
+ pendingSnapshots[name] = mine to json
473
+ writer.execute {
474
+ val file = snapshotFile(name)
475
+ val ok = runCatching {
476
+ if (json == null) {
477
+ !file.exists() || file.delete()
478
+ } else {
479
+ snapshotFiles.set(file, json)
480
+ true
481
+ }
482
+ }.getOrDefault(false)
483
+ if (ok) {
484
+ if (json == null) snapshotCache.remove(name) else snapshotCache[name] = json
485
+ }
486
+ val current = synchronized(pendingSnapshots) {
487
+ if (pendingSnapshots[name]?.first == mine) pendingSnapshots.remove(name)
488
+ snapshot(name)
489
+ }
490
+ main.post {
491
+ // Isolates optimistically display their write. Each is
492
+ // told what a read gives now: the checkpoint when it was
493
+ // kept, the confirmed value when it was not, and a newer
494
+ // one still on its way in either case, so an older write
495
+ // settling cannot set a screen back behind it.
496
+ changed("snapshots", name, current, null)
497
+ done(ok)
437
498
  }
438
- }.getOrDefault(false)
439
- if (ok) {
440
- if (json == null) snapshotCache.remove(name) else snapshotCache[name] = json
441
- }
442
- main.post {
443
- // Isolates optimistically display their write. Restore the
444
- // confirmed value before rejecting an unsuccessful checkpoint.
445
- changed("snapshots", name, if (ok) json else previous, null)
446
- done(ok)
447
499
  }
448
500
  }
449
501
  }
450
502
 
451
503
  /** `craft.snapshots.clear(keep)`: every snapshot but those named, as a sign-out does. */
452
504
  fun clearSnapshots(keep: Set<String>, done: (Boolean) -> Unit) {
453
- writer.execute {
454
- val removed = mutableListOf<String>()
455
- var ok = true
456
- snapshotsDir.listFiles()?.forEach { file ->
457
- if (!file.name.endsWith(".json")) return@forEach
458
- val name = snapshotName(file) ?: return@forEach
459
- if (name in keep) return@forEach
460
- if (file.delete()) removed.add(name) else ok = false
461
- }
462
- removed.forEach(snapshotCache::remove)
463
- main.post {
464
- removed.forEach { changed("snapshots", it, null, null) }
465
- done(ok)
505
+ synchronized(pendingSnapshots) {
506
+ // Gone for a reader from this moment, as on UIKit, where a clear
507
+ // is a delete of every name.
508
+ snapshotRevision += 1
509
+ val mine = snapshotRevision
510
+ val doomed = currentSnapshots().keys.filter { it !in keep }
511
+ doomed.forEach { pendingSnapshots[it] = mine to null }
512
+ writer.execute {
513
+ val removed = mutableListOf<String>()
514
+ var ok = true
515
+ snapshotsDir.listFiles()?.forEach { file ->
516
+ if (!file.name.endsWith(".json")) return@forEach
517
+ val name = snapshotName(file) ?: return@forEach
518
+ if (name in keep) return@forEach
519
+ if (file.delete()) removed.add(name) else ok = false
520
+ }
521
+ removed.forEach(snapshotCache::remove)
522
+ val current = synchronized(pendingSnapshots) {
523
+ doomed.forEach { if (pendingSnapshots[it]?.first == mine) pendingSnapshots.remove(it) }
524
+ // One that could not be deleted is still there, and one
525
+ // written since the clear was asked for is on its way.
526
+ (removed + doomed).distinct().associateWith { snapshot(it) }
527
+ }
528
+ main.post {
529
+ current.forEach { (name, json) -> changed("snapshots", name, json, null) }
530
+ done(ok)
531
+ }
466
532
  }
467
533
  }
468
534
  }
@@ -905,6 +971,7 @@ internal class CraftNativeChrome(
905
971
  private val fonts: CraftNativeChromeFonts = CraftNativeChromeFonts(),
906
972
  ) : LinearLayout(context) {
907
973
  val content = FrameLayout(context)
974
+ private val body = FrameLayout(context)
908
975
  private val bar = LinearLayout(context)
909
976
  private val backButton = ImageView(context)
910
977
  private val leadingButtons = LinearLayout(context)
@@ -915,6 +982,7 @@ internal class CraftNativeChrome(
915
982
  private val tabRow = LinearLayout(context)
916
983
  private val accessory = FrameLayout(context)
917
984
  private val companionRow = LinearLayout(context)
985
+ private val accessoryGroup = LinearLayout(context)
918
986
  private val companionViews = mutableListOf<Pair<FrameLayout, JSONObject>>()
919
987
  private var renderedCompanionSpecs = ""
920
988
  private val accessoryIcon = ImageView(context)
@@ -995,7 +1063,8 @@ internal class CraftNativeChrome(
995
1063
  largeTitleView.ellipsize = TextUtils.TruncateAt.END
996
1064
  largeTitleView.setPadding(dp(16), 0, dp(16), dp(8))
997
1065
  addView(largeTitleView, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
998
- addView(content, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
1066
+ body.addView(content, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
1067
+ addView(body, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
999
1068
  tabBar.orientation = HORIZONTAL
1000
1069
  tabBar.gravity = Gravity.CENTER
1001
1070
  tabs.forEachIndexed { index, tab ->
@@ -1045,7 +1114,9 @@ internal class CraftNativeChrome(
1045
1114
  tabRow.addView(tabBar, LayoutParams(0, dp(64), 1f))
1046
1115
  companionRow.orientation = HORIZONTAL
1047
1116
  companionRow.gravity = Gravity.CENTER_VERTICAL
1048
- tabRow.addView(companionRow, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(64)))
1117
+ accessoryGroup.orientation = HORIZONTAL
1118
+ accessoryGroup.gravity = Gravity.CENTER
1119
+ accessoryGroup.addView(companionRow, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
1049
1120
  accessory.addView(accessoryIcon, FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
1050
1121
  accessoryBadge.textSize = 10f
1051
1122
  accessoryBadge.gravity = Gravity.CENTER
@@ -1055,7 +1126,8 @@ internal class CraftNativeChrome(
1055
1126
  accessory.addView(accessoryBadge, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(18), Gravity.TOP or Gravity.END))
1056
1127
  accessory.setOnClickListener { accessorySpec?.optString("path")?.let { onAccessory?.invoke(it) } }
1057
1128
  accessory.visibility = View.GONE
1058
- tabRow.addView(accessory, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(8) })
1129
+ accessoryGroup.addView(accessory, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(8) })
1130
+ tabRow.addView(accessoryGroup, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
1059
1131
  addView(tabRow, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(72)))
1060
1132
  tabRow.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
1061
1133
  refreshColors()
@@ -1067,7 +1139,8 @@ internal class CraftNativeChrome(
1067
1139
  val overHorizontalScroll = onTabSwipeShouldDefer?.invoke(event.x, event.y) == true
1068
1140
  horizontalScrollDeferred = overHorizontalScroll
1069
1141
  if (overHorizontalScroll) parent?.requestDisallowInterceptTouchEvent(true)
1070
- swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabRow.visibility == View.VISIBLE && event.y < tabRow.top
1142
+ val overActions = android.graphics.Rect().let { accessoryGroup.getGlobalVisibleRect(it) && it.contains(event.rawX.toInt(), event.rawY.toInt()) }
1143
+ swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overActions && !overHorizontalScroll && tabRow.visibility == View.VISIBLE && event.y < tabRow.top
1071
1144
  swipeIntercepted = false
1072
1145
  swipeDownX = event.x
1073
1146
  swipeDownY = event.y
@@ -1211,10 +1284,45 @@ internal class CraftNativeChrome(
1211
1284
  accessoryBadge.text = it.optString("badge").takeUnless { badge -> badge == "null" }.orEmpty().take(8)
1212
1285
  accessoryBadge.visibility = if (accessoryBadge.text.isEmpty()) View.GONE else View.VISIBLE
1213
1286
  }
1287
+ layoutAccessoryGroup(tabBarHidden)
1214
1288
  tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)
1215
1289
  refreshColors()
1216
1290
  }
1217
1291
 
1292
+ /** The same actions and popup anchors survive switching between grouped and separate chrome. */
1293
+ private fun layoutAccessoryGroup(hidden: Boolean) {
1294
+ val layout = accessorySpec?.optString("layout").orEmpty()
1295
+ val grouped = layout == "vertical" || layout == "horizontal"
1296
+ val parent = if (grouped) body else tabRow
1297
+ if (accessoryGroup.parent !== parent) {
1298
+ (accessoryGroup.parent as? ViewGroup)?.removeView(accessoryGroup)
1299
+ if (grouped) body.addView(accessoryGroup, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply {
1300
+ marginEnd = dp(20)
1301
+ bottomMargin = dp(8)
1302
+ }) else tabRow.addView(accessoryGroup, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
1303
+ }
1304
+ val vertical = layout == "vertical"
1305
+ accessoryGroup.orientation = if (vertical) VERTICAL else HORIZONTAL
1306
+ companionRow.orientation = accessoryGroup.orientation
1307
+ accessoryGroup.setPadding(if (grouped) dp(2) else 0, if (grouped) dp(2) else 0, if (grouped) dp(2) else 0, if (grouped) dp(2) else 0)
1308
+ accessoryGroup.visibility = if (accessorySpec == null || hidden || tabs.isEmpty()) View.GONE else View.VISIBLE
1309
+ accessoryGroup.tag = if (grouped) "craft-tab-accessory-group" else null
1310
+ accessoryGroup.elevation = if (grouped) dp(6).toFloat() else 0f
1311
+ accessoryGroup.clipToOutline = grouped
1312
+ companionViews.forEach { (action, _) ->
1313
+ action.layoutParams = LayoutParams(dp(48), dp(48)).apply { marginStart = if (grouped) 0 else dp(8) }
1314
+ }
1315
+ accessory.layoutParams = LayoutParams(dp(if (grouped) 48 else 64), dp(if (grouped) 48 else 64)).apply { marginStart = if (grouped) 0 else dp(8) }
1316
+ val divider = GradientDrawable().apply { setColor(Color.argb(48, 128, 128, 128)); setSize(dp(1), dp(1)) }
1317
+ for (row in listOf(accessoryGroup, companionRow)) {
1318
+ row.showDividers = if (grouped) SHOW_DIVIDER_MIDDLE else SHOW_DIVIDER_NONE
1319
+ row.dividerDrawable = if (grouped) divider.constantState?.newDrawable() else null
1320
+ row.dividerPadding = dp(12)
1321
+ }
1322
+ // An empty companion row must not leave a divider above a single icon.
1323
+ companionRow.visibility = if (companionViews.isEmpty()) View.GONE else View.VISIBLE
1324
+ }
1325
+
1218
1326
  fun selectTab(index: Int) {
1219
1327
  selectedTab = index
1220
1328
  refreshColors()
@@ -1309,16 +1417,18 @@ internal class CraftNativeChrome(
1309
1417
  largeTitleView.setTextColor(navigationTitleColor ?: text)
1310
1418
  backButton.imageTintList = ColorStateList.valueOf(accent)
1311
1419
  val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
1420
+ val grouped = accessoryGroup.parent === body
1421
+ accessoryGroup.background = if (grouped) GradientDrawable().apply { setColor(surface); cornerRadius = dp(26).toFloat() } else null
1312
1422
  tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
1313
1423
  val primarySelected = accessorySpec?.let { isAccessorySelected(it) } == true
1314
1424
  accessory.isSelected = primarySelected
1315
1425
  companionViews.forEach { (action, item) ->
1316
1426
  val selected = isAccessorySelected(item)
1317
1427
  action.isSelected = selected
1318
- action.background = GradientDrawable().apply { setColor(if (selected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1428
+ 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() }
1319
1429
  (action.getChildAt(0) as ImageView).setImageDrawable(CraftNativeIcons.drawable(context, null, item.optString("symbol"), if (selected) accent else text))
1320
1430
  }
1321
- accessory.background = GradientDrawable().apply { setColor(if (primarySelected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1431
+ accessory.background = if (grouped) rippleBackground() else GradientDrawable().apply { setColor(if (primarySelected) Color.argb(36, Color.red(accent), Color.green(accent), Color.blue(accent)) else surface); cornerRadius = dp(32).toFloat() }
1322
1432
  accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), if (primarySelected) accent else text)) }
1323
1433
  tabViews.forEachIndexed { index, (icon, label) ->
1324
1434
  tabBar.getChildAt(index).isSelected = index == selectedTab && !accessorySelected
@@ -2140,3 +2250,182 @@ internal object CraftNativeIcons {
2140
2250
  override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
2141
2251
  }
2142
2252
  }
2253
+
2254
+ /**
2255
+ * Whether `text` is JSON that UIKit's own writer would also accept.
2256
+ *
2257
+ * UIKit refuses a snapshot whose payload is not JSON, before it touches the
2258
+ * disk: `JSONSerialization.jsonObject(with:options: [.fragmentsAllowed])`
2259
+ * returning nil fails the write. Android had no such check, so a payload iOS
2260
+ * rejected was written here and the two hosts disagreed about whether a
2261
+ * checkpoint had been kept (#396).
2262
+ *
2263
+ * The grammar is Foundation's, measured rather than assumed, because it is
2264
+ * not RFC 8259 and a strict parser would have diverged in the other
2265
+ * direction:
2266
+ *
2267
+ * - top-level fragments are allowed (`42`, `"text"`, `null`);
2268
+ * - **one** trailing comma is allowed in an object or array — `{"a":1,}`
2269
+ * and `[1,]` are accepted, `{"a":1,,}` and `[,1]` are not;
2270
+ * - a lone surrogate escape is rejected, a matched pair accepted;
2271
+ * - a raw control character inside a string is rejected, its escape is not;
2272
+ * - `00`, `+1`, `.5`, `1.`, `1.0e`, `NaN` and `Infinity` are all rejected;
2273
+ * - trailing content after the value is rejected, surrounding space is not.
2274
+ *
2275
+ * `org.json` cannot stand in for this: `JSONTokener` accepts unquoted keys,
2276
+ * single quotes and more, so it would admit payloads UIKit refuses.
2277
+ * `android.util.JsonReader` in strict mode refuses top-level fragments that
2278
+ * UIKit accepts. Both are wrong in a direction that matters, which is why
2279
+ * this is scanned here and pinned against Foundation itself by
2280
+ * `packages/android/src/snapshot-json-contract.test.ts`.
2281
+ */
2282
+ private class CraftNativeJsonScanner(private val text: String) {
2283
+ private var at = 0
2284
+
2285
+ fun validate(): Boolean {
2286
+ skipWhitespace()
2287
+ if (!value()) return false
2288
+ skipWhitespace()
2289
+ return at == text.length
2290
+ }
2291
+
2292
+ private fun skipWhitespace() {
2293
+ while (at < text.length && (text[at] == ' ' || text[at] == '\t' || text[at] == '\n' || text[at] == '\r')) at++
2294
+ }
2295
+
2296
+ private fun value(): Boolean {
2297
+ if (at >= text.length) return false
2298
+ return when (text[at]) {
2299
+ '{' -> obj()
2300
+ '[' -> arr()
2301
+ '"' -> string()
2302
+ 't' -> literal("true")
2303
+ 'f' -> literal("false")
2304
+ 'n' -> literal("null")
2305
+ else -> number()
2306
+ }
2307
+ }
2308
+
2309
+ private fun literal(word: String): Boolean {
2310
+ if (!text.startsWith(word, at)) return false
2311
+ at += word.length
2312
+ return true
2313
+ }
2314
+
2315
+ /** After a member, consume the separator. Returns null when malformed. */
2316
+ private fun afterMember(closing: Char): Boolean? {
2317
+ skipWhitespace()
2318
+ if (at >= text.length) return null
2319
+ return when (text[at]) {
2320
+ ',' -> {
2321
+ at++
2322
+ skipWhitespace()
2323
+ // Foundation allows exactly one trailing comma before the
2324
+ // close. A second comma, or a close that never comes, is not.
2325
+ if (at < text.length && text[at] == closing) { at++; true } else false
2326
+ }
2327
+ closing -> { at++; true }
2328
+ else -> null
2329
+ }
2330
+ }
2331
+
2332
+ private fun obj(): Boolean {
2333
+ at++
2334
+ skipWhitespace()
2335
+ if (at < text.length && text[at] == '}') { at++; return true }
2336
+ while (true) {
2337
+ skipWhitespace()
2338
+ if (at >= text.length || text[at] != '"') return false
2339
+ if (!string()) return false
2340
+ skipWhitespace()
2341
+ if (at >= text.length || text[at] != ':') return false
2342
+ at++
2343
+ skipWhitespace()
2344
+ if (!value()) return false
2345
+ val done = afterMember('}') ?: return false
2346
+ if (done) return true
2347
+ }
2348
+ }
2349
+
2350
+ private fun arr(): Boolean {
2351
+ at++
2352
+ skipWhitespace()
2353
+ if (at < text.length && text[at] == ']') { at++; return true }
2354
+ while (true) {
2355
+ skipWhitespace()
2356
+ if (!value()) return false
2357
+ val done = afterMember(']') ?: return false
2358
+ if (done) return true
2359
+ }
2360
+ }
2361
+
2362
+ private fun string(): Boolean {
2363
+ at++
2364
+ var pendingHighSurrogate = false
2365
+ while (at < text.length) {
2366
+ val character = text[at]
2367
+ when {
2368
+ character == '"' -> { at++; return !pendingHighSurrogate }
2369
+ character == '\\' -> {
2370
+ at++
2371
+ if (at >= text.length) return false
2372
+ val escape = text[at]
2373
+ if (escape == 'u') {
2374
+ if (at + 4 >= text.length) return false
2375
+ var code = 0
2376
+ for (offset in 1..4) {
2377
+ val digit = Character.digit(text[at + offset], 16)
2378
+ if (digit < 0) return false
2379
+ code = code * 16 + digit
2380
+ }
2381
+ at += 5
2382
+ val high = code in 0xD800..0xDBFF
2383
+ val low = code in 0xDC00..0xDFFF
2384
+ // A low surrogate is only legal directly after a high
2385
+ // one, and a high one must be followed by a low one.
2386
+ if (pendingHighSurrogate != low) return false
2387
+ pendingHighSurrogate = high
2388
+ continue
2389
+ }
2390
+ if (pendingHighSurrogate) return false
2391
+ if (escape !in listOf('"', '\\', '/', 'b', 'f', 'n', 'r', 't')) return false
2392
+ at++
2393
+ }
2394
+ character.code < 0x20 -> return false
2395
+ else -> {
2396
+ if (pendingHighSurrogate) return false
2397
+ at++
2398
+ }
2399
+ }
2400
+ }
2401
+ return false
2402
+ }
2403
+
2404
+ private fun number(): Boolean {
2405
+ val start = at
2406
+ if (at < text.length && text[at] == '-') at++
2407
+ val integerStart = at
2408
+ while (at < text.length && text[at] in '0'..'9') at++
2409
+ if (at == integerStart) return false
2410
+ // A leading zero may not be followed by another digit.
2411
+ if (text[integerStart] == '0' && at - integerStart > 1) return false
2412
+ if (at < text.length && text[at] == '.') {
2413
+ at++
2414
+ val fractionStart = at
2415
+ while (at < text.length && text[at] in '0'..'9') at++
2416
+ if (at == fractionStart) return false
2417
+ }
2418
+ if (at < text.length && (text[at] == 'e' || text[at] == 'E')) {
2419
+ at++
2420
+ if (at < text.length && (text[at] == '+' || text[at] == '-')) at++
2421
+ val exponentStart = at
2422
+ while (at < text.length && text[at] in '0'..'9') at++
2423
+ if (at == exponentStart) return false
2424
+ }
2425
+ return at > start
2426
+ }
2427
+ }
2428
+
2429
+ /** Whether a snapshot payload is JSON both hosts accept. */
2430
+ private fun craftNativeIsJsonText(text: String): Boolean = CraftNativeJsonScanner(text).validate()
2431
+
@@ -26,7 +26,17 @@ internal class CraftNativeSounds(
26
26
  ) {
27
27
  private data class Sound(
28
28
  val player: MediaPlayer,
29
+ /** Where the player is right now, part-way through a fade included. */
29
30
  var volume: Float,
31
+ /**
32
+ * The volume that was asked for. A fade moves `volume` toward or away
33
+ * from this and must not overwrite it: with one number for both, a
34
+ * pause during a fade left the sound at whatever fraction the fade had
35
+ * reached, and `resume` played it there for the rest of its life —
36
+ * `play({ volume: 1, fadeIn: 2000 })` paused at 500 ms came back at
37
+ * 0.25. UIKit keeps the two apart and resumes at the asked-for volume.
38
+ */
39
+ var asked: Float,
30
40
  var paused: Boolean = false,
31
41
  var interrupted: Boolean = false,
32
42
  var prepared: Boolean = false,
@@ -66,7 +76,7 @@ internal class CraftNativeSounds(
66
76
  // Its player, pending load and cancellation handle still belong to it.
67
77
  stop(id, 0.0)
68
78
  val player = MediaPlayer()
69
- val sound = Sound(player, if (fadeIn > 0) 0f else volume, pending = done)
79
+ val sound = Sound(player, if (fadeIn > 0) 0f else volume, asked = volume, pending = done)
70
80
  sounds[id] = sound
71
81
  fun settle(result: CraftNativeCapabilityResult) {
72
82
  val answer = sound.pending ?: return
@@ -144,7 +154,19 @@ internal class CraftNativeSounds(
144
154
  fun resume(id: String): Boolean {
145
155
  val sound = sounds[id] ?: return false
146
156
  if (focusSuspended) return false
147
- if (sound.prepared) runCatching { sound.player.start() }.onFailure { return false }
157
+ // UIKit answers false for a sound that is not paused, and a caller
158
+ // reading the answer to learn whether anything was resumed should not
159
+ // get a different one here.
160
+ if (!sound.paused) return false
161
+ if (sound.prepared) {
162
+ // A pause cancels a fade where it stands. Land on the volume that
163
+ // was asked for rather than on the fraction the fade had reached.
164
+ // Left alone until the player is prepared: a fade-in that has not
165
+ // begun yet still starts from silence when it does.
166
+ sound.volume = sound.asked
167
+ applyVolume(sound)
168
+ runCatching { sound.player.start() }.onFailure { return false }
169
+ }
148
170
  sound.paused = false
149
171
  sound.interrupted = false
150
172
  return true
@@ -154,6 +176,7 @@ internal class CraftNativeSounds(
154
176
  val sound = sounds[id] ?: return false
155
177
  if (!volume.isFinite() || !fade.isFinite() || fade < 0) return false
156
178
  val target = volume.coerceIn(0.0, 1.0).toFloat()
179
+ sound.asked = target
157
180
  sound.fade?.let(main::removeCallbacks)
158
181
  sound.fade = null
159
182
  if (fade > 0 && !sound.paused) ramp(sound, target, fade / 1000.0)
@@ -149,6 +149,27 @@ private fun craftNativeBoolean(value: Any?, fallback: Boolean = false): Boolean
149
149
 
150
150
  private fun craftNativeInt(value: Any?, fallback: Int): Int = (value as? Number)?.toInt() ?: fallback
151
151
 
152
+ /**
153
+ * Where a field's text and hint begin, vertically.
154
+ *
155
+ * The platform's `Widget.EditText` carries `gravity = center_vertical`, and
156
+ * nothing between it and this app overrides it: `Theme.CraftApp` inherits
157
+ * `Theme.Material3.DayNight.NoActionBar`, no Material3 theme binds
158
+ * `android:editTextStyle`, and AppCompat binds it to
159
+ * `Widget.AppCompat.EditText`, whose chain reaches `android:Widget.EditText`
160
+ * without touching gravity. A single-line field is never taller than its
161
+ * text, so the centring cannot show; a field given a `minHeight` drew its
162
+ * first line — and its hint — halfway down the box, where iOS and the web
163
+ * both start at the top (#502).
164
+ *
165
+ * Only the vertical bits are replaced. The horizontal ones are left exactly
166
+ * as they were, because `textAlign` resolves through `textAlignment`, whose
167
+ * default of `TEXT_ALIGNMENT_INHERIT` can still fall back to this gravity.
168
+ */
169
+ private fun craftNativeVerticalGravity(current: Int, multiline: Boolean): Int =
170
+ (current and Gravity.VERTICAL_GRAVITY_MASK.inv()) or (if (multiline) Gravity.TOP else Gravity.CENTER_VERTICAL)
171
+
172
+
152
173
  /**
153
174
  * A scroll event's geometry in density-independent units.
154
175
  *
@@ -6689,6 +6710,8 @@ class MainActivity : AppCompatActivity() {
6689
6710
  if (isSingleLine == multiline) isSingleLine = !multiline
6690
6711
  val wantedLines = if (multiline) craftNativeInt(props.opt("numberOfLines"), Int.MAX_VALUE).coerceAtLeast(1) else 1
6691
6712
  if (maxLines != wantedLines) maxLines = wantedLines
6713
+ val wantedGravity = craftNativeVerticalGravity(gravity, multiline)
6714
+ if (gravity != wantedGravity) gravity = wantedGravity
6692
6715
  isEnabled = craftNativeBoolean(props.opt("editable"), true)
6693
6716
  configureTextStyle(this, style)
6694
6717
  // The style's padding is where the text starts, as on iOS and
package/dist/cli.js CHANGED
@@ -1025,6 +1025,9 @@ async function detachDiskImage(mount, options = {}) {
1025
1025
  await wait(attempt * 2000);
1026
1026
  }
1027
1027
  }
1028
+ function attachedDevice(attachOutput) {
1029
+ return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
1030
+ }
1028
1031
  async function createDiskImage(opts) {
1029
1032
  if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
1030
1033
  return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
@@ -1068,7 +1071,7 @@ async function createDiskImage(opts) {
1068
1071
  phase = "layout";
1069
1072
  writeInstallerLayout(mount, opts.volumeName, bundleName);
1070
1073
  phase = "detach";
1071
- const detached = await detachDiskImage(mount);
1074
+ const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
1072
1075
  if (!detached.success) {
1073
1076
  detachAttempts = detached.attempts;
1074
1077
  throw new Error(detached.error);
@@ -1083,7 +1086,7 @@ async function createDiskImage(opts) {
1083
1086
  Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
1084
1087
  } finally {
1085
1088
  if (mounted)
1086
- await runTool("hdiutil", ["detach", mount, "-force"]);
1089
+ await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
1087
1090
  removeTempDir(scratch);
1088
1091
  }
1089
1092
  }
@@ -3243,7 +3246,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3243
3246
  `);
3244
3247
  }
3245
3248
  // package.json
3246
- var version = "0.0.490";
3249
+ var version = "0.0.492";
3247
3250
 
3248
3251
  // bin/cli.ts
3249
3252
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.490";
379
+ var version = "0.0.492";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
@@ -1393,6 +1393,9 @@ async function detachDiskImage(mount, options = {}) {
1393
1393
  await wait(attempt * 2000);
1394
1394
  }
1395
1395
  }
1396
+ function attachedDevice(attachOutput) {
1397
+ return attachOutput.match(/^(\/dev\/disk\d+)(?=\s)/m)?.[1] ?? null;
1398
+ }
1396
1399
  async function createDiskImage(opts) {
1397
1400
  if (!/^[^/:\n]{1,27}$/.test(opts.volumeName))
1398
1401
  return { success: false, error: `Invalid DMG volume name "${opts.volumeName}"; must be 1..27 chars without /, :, or newline` };
@@ -1436,7 +1439,7 @@ async function createDiskImage(opts) {
1436
1439
  phase = "layout";
1437
1440
  writeInstallerLayout(mount, opts.volumeName, bundleName);
1438
1441
  phase = "detach";
1439
- const detached = await detachDiskImage(mount);
1442
+ const detached = await detachDiskImage(attachedDevice(attachment) ?? mount);
1440
1443
  if (!detached.success) {
1441
1444
  detachAttempts = detached.attempts;
1442
1445
  throw new Error(detached.error);
@@ -1451,7 +1454,7 @@ async function createDiskImage(opts) {
1451
1454
  Disk image lifecycle: ${JSON.stringify({ phase, image: writable, mount, attachment, detachAttempts })}` };
1452
1455
  } finally {
1453
1456
  if (mounted)
1454
- await runTool("hdiutil", ["detach", mount, "-force"]);
1457
+ await runTool("hdiutil", ["detach", attachedDevice(attachment) ?? mount, "-force"]);
1455
1458
  removeTempDir(scratch);
1456
1459
  }
1457
1460
  }