craft-native 0.0.247 → 0.0.248

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.
Files changed (41) hide show
  1. package/dist/android/src/index.d.ts +2 -0
  2. package/dist/android/src/index.js +22 -18
  3. package/dist/android/templates/CraftNative.kt.template +17 -0
  4. package/dist/android/templates/CraftNativeFlatList.kt.template +115 -40
  5. package/dist/android/templates/CraftNativeMapGoogle.kt.template +92 -5
  6. package/dist/android/templates/CraftNativeMapLibre.kt.template +90 -3
  7. package/dist/android/templates/CraftNativeMutation.kt.template +32 -0
  8. package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -2
  9. package/dist/android/templates/CraftNativeServices.kt.template +25 -0
  10. package/dist/android/templates/CraftNativeShell.kt.template +34 -8
  11. package/dist/android/templates/MainActivityNative.kt.template +369 -69
  12. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  13. package/dist/api/mobile.d.ts +37 -1
  14. package/dist/browser.js +9 -9
  15. package/dist/cli.js +90 -673
  16. package/dist/components/index.d.ts +50 -9
  17. package/dist/index.cjs +394 -438
  18. package/dist/index.d.cts +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +446 -493
  21. package/dist/ios/src/index.d.ts +2 -0
  22. package/dist/ios/src/index.js +54 -53
  23. package/dist/ios/templates/CraftApp.swift +37 -2
  24. package/dist/ios/templates/CraftHealth.swift +217 -0
  25. package/dist/ios/templates/CraftHeartRate.swift +6 -3
  26. package/dist/ios/templates/CraftHybrid.swift +18 -19
  27. package/dist/ios/templates/CraftLiveActivities.swift +1 -1
  28. package/dist/ios/templates/CraftNativeActions.swift +87 -8
  29. package/dist/ios/templates/CraftNativeFiles.swift +15 -3
  30. package/dist/ios/templates/CraftNativeFlatList.swift +18 -1
  31. package/dist/ios/templates/CraftNativeMutation.swift +36 -0
  32. package/dist/ios/templates/CraftNativeScreen.swift +813 -65
  33. package/dist/ios/templates/CraftNativeServices.swift +4 -2
  34. package/dist/ios/templates/CraftNativeUI.swift +10 -2
  35. package/dist/ios/templates/testing/DriverTests.swift.template +1 -0
  36. package/dist/ios/templates/testing/HostApp.swift +9 -2
  37. package/dist/macos/templates/CraftNativeMutation.swift +36 -0
  38. package/dist/macos/templates/native_layout.zig +226 -2
  39. package/dist/mobile-testing.js +2 -2
  40. package/dist/mobile.js +101 -63
  41. package/package.json +1 -1
@@ -113,6 +113,7 @@ import org.json.JSONArray
113
113
  import org.json.JSONObject
114
114
  import java.io.File
115
115
  import java.net.HttpURLConnection
116
+ import java.net.URI
116
117
  import java.net.URL
117
118
  import java.text.SimpleDateFormat
118
119
  import java.util.Calendar
@@ -166,7 +167,7 @@ private fun craftNativeFontWeight(value: Any?): String? = when (value) {
166
167
  internal fun craftNativePadding(style: JSONObject?, name: String, fallback: Int, density: Float): Int {
167
168
  val value = craftNativeNumber(style?.opt(name)) ?: Double.NaN
168
169
  val pixels = value * density
169
- return if (value.isFinite() && value >= 0 && pixels.isFinite()) pixels.toInt() else fallback
170
+ return if (value.isFinite() && value >= 0 && pixels.isFinite()) pixels.roundToInt() else fallback
170
171
  }
171
172
 
172
173
  internal fun craftNativeFiniteFloat(style: JSONObject?, name: String, fallback: Float): Float {
@@ -271,6 +272,18 @@ private fun craftNativeGridColumnCount(value: Any?): Int {
271
272
  return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
272
273
  }
273
274
 
275
+ private fun craftNativeGridRowCount(value: Any?): Int? {
276
+ if (value is Number) return max(1, value.toInt())
277
+ val text = value as? String ?: return null
278
+ val trimmed = text.trim()
279
+ if (trimmed.isEmpty()) return null
280
+ if (trimmed.startsWith("repeat(")) {
281
+ val count = trimmed.removePrefix("repeat(").takeWhile { it.isDigit() }.toIntOrNull()
282
+ if (count != null) return max(1, count)
283
+ }
284
+ return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
285
+ }
286
+
274
287
  /**
275
288
  * A CSS colour as Android's ARGB. STX writes `#RRGGBBAA` (and `#RGBA`) for a
276
289
  * colour with alpha, where Android's parser reads `#AARRGGBB`: a translucent
@@ -709,7 +722,7 @@ internal class CraftNativeCelebration(private val view: View) {
709
722
  }
710
723
 
711
724
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
712
- private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
725
+ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
713
726
  private var celebration: CraftNativeCelebration? = null
714
727
  fun configureCelebration(raw: JSONObject?, radius: Float) {
715
728
  if (raw != null && celebration == null) celebration = CraftNativeCelebration(this)
@@ -737,6 +750,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
737
750
  private var columnGap = 0f
738
751
  private var grid = false
739
752
  private var gridColumns = 1
753
+ private var gridRows: Int? = null
740
754
  private var gridAutoRows: CraftNativeLength? = null
741
755
  private var reverse = false
742
756
  private var wrapReverse = false
@@ -873,6 +887,37 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
873
887
  child.layout(left, top, right, bottom)
874
888
  }
875
889
 
890
+ /**
891
+ * Resolve an absolute child against the parent's content box before it is
892
+ * placed. In particular, left+right anchors define the child's width; an
893
+ * aspect-ratio child must be measured again at that width or Android can
894
+ * retain the parent's intrinsic height from its first, wider probe.
895
+ */
896
+ private fun absoluteSize(entry: Entry, contentWidth: Int, contentHeight: Int): Pair<Int, Int> {
897
+ val style = entry.style
898
+ val anchoredWidth = style.left != null && style.right != null
899
+ val anchoredHeight = style.top != null && style.bottom != null
900
+ var width = when {
901
+ anchoredWidth -> contentWidth - style.left!! - style.right!!
902
+ style.width != null -> style.width.resolve(contentWidth) ?: entry.width
903
+ style.aspectRatio != null && style.height != null ->
904
+ ((style.height.resolve(contentHeight) ?: entry.height) * style.aspectRatio).roundToInt()
905
+ else -> entry.width
906
+ }
907
+ var height = if (anchoredHeight) contentHeight - style.top!! - style.bottom!! else entry.height
908
+ width = clamp(width, style.minWidth?.resolve(contentWidth), style.maxWidth?.resolve(contentWidth))
909
+ if (!anchoredHeight) {
910
+ height = when {
911
+ style.height != null -> measureChildHeight(entry.view, style, contentHeight, 0, width)
912
+ anchoredWidth && style.aspectRatio != null && style.aspectRatio > 0 ->
913
+ clamp((width / style.aspectRatio).roundToInt(), style.minHeight?.resolve(contentHeight), style.maxHeight?.resolve(contentHeight))
914
+ else -> measureChildHeight(entry.view, style, contentHeight, 0, width)
915
+ }
916
+ }
917
+ height = clamp(height, style.minHeight?.resolve(contentHeight), style.maxHeight?.resolve(contentHeight))
918
+ return max(0, width) to max(0, height)
919
+ }
920
+
876
921
  override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
877
922
  val skew = CraftNativeSkewTransforms.matrix(child) ?: return super.drawChild(canvas, child, drawingTime)
878
923
  val save = canvas.save()
@@ -905,6 +950,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
905
950
  gridColumns = craftNativeGridColumnCount(
906
951
  if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
907
952
  )
953
+ gridRows = craftNativeGridRowCount(style.opt("gridTemplateRows"))
908
954
  gridAutoRows = CraftNativeLength.from(style.opt("gridAutoRows"), density)
909
955
  val all = craftNativePadding(style, "padding", 0, density)
910
956
  val horizontal = craftNativePadding(style, "paddingHorizontal", all, density)
@@ -1081,12 +1127,20 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1081
1127
  rows.last() += entry
1082
1128
  }
1083
1129
  }
1084
- val rowHeights = rows.map { row ->
1130
+ val naturalRowHeights = rows.map { row ->
1085
1131
  max((gridAutoRows?.resolve(height - paddingTop - paddingBottom) ?: 0).toFloat(), row.maxOfOrNull { entry ->
1086
1132
  val lp = entry.view.layoutParams as? MarginLayoutParams
1087
1133
  (entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)).toFloat()
1088
1134
  } ?: 0f)
1089
1135
  }
1136
+ val rowHeights = if (gridRows != null) {
1137
+ val count = max(gridRows ?: 0, naturalRowHeights.size)
1138
+ while (rows.size < count) rows.add(mutableListOf())
1139
+ val available = max(0f, height - paddingTop - paddingBottom - max(0, count - 1) * rowGap)
1140
+ val equal = available / max(1, count).toFloat()
1141
+ (naturalRowHeights + List(count - naturalRowHeights.size) { equal })
1142
+ .map { max(it, equal) }
1143
+ } else naturalRowHeights
1090
1144
  return GridLayout(rows, absolute, cellWidth, rowHeights)
1091
1145
  }
1092
1146
 
@@ -1236,6 +1290,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1236
1290
  val grid: Boolean,
1237
1291
  val gridColumns: Int,
1238
1292
  val gridAutoRows: CraftNativeLength?,
1293
+ val gridRows: Int?,
1239
1294
  val reverse: Boolean,
1240
1295
  val wrapReverse: Boolean,
1241
1296
  val children: List<SharedTreeNode>,
@@ -1287,6 +1342,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1287
1342
  grid = nested?.grid ?: false,
1288
1343
  gridColumns = nested?.gridColumns ?: 1,
1289
1344
  gridAutoRows = nested?.gridAutoRows,
1345
+ gridRows = nested?.gridRows,
1290
1346
  reverse = nested?.reverse ?: false,
1291
1347
  wrapReverse = nested?.wrapReverse ?: false,
1292
1348
  children = children.map { child -> sharedTreeNode(child) ?: return null },
@@ -1315,6 +1371,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1315
1371
  grid = grid,
1316
1372
  gridColumns = gridColumns,
1317
1373
  gridAutoRows = gridAutoRows,
1374
+ gridRows = gridRows,
1318
1375
  reverse = reverse,
1319
1376
  wrapReverse = wrapReverse,
1320
1377
  children = children,
@@ -1331,7 +1388,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1331
1388
  val reverseAware = nodes.any { it.reverse || it.wrapReverse }
1332
1389
  val wrapReverseAware = nodes.any { it.wrapReverse }
1333
1390
  val autoRowsAware = nodes.any { it.gridAutoRows != null }
1391
+ val explicitRowsAware = nodes.any { it.gridRows != null }
1334
1392
  val fields = when {
1393
+ explicitRowsAware -> 35
1335
1394
  autoRowsAware -> 34
1336
1395
  reverseAware -> 33
1337
1396
  else -> 32
@@ -1384,13 +1443,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1384
1443
  put(base, 31, node.columnGap)
1385
1444
  if (fields >= 33) putEnum(base, 32, (if (node.reverse) 1 else 0) or (if (node.wrapReverse) 2 else 0))
1386
1445
  if (autoRowsAware) put(base, 33, sharedLength(node.gridAutoRows, contentHeight))
1446
+ if (explicitRowsAware) put(base, 34, (node.gridRows ?: 0).toFloat())
1387
1447
  val firstChild = cursor
1388
1448
  node.children.forEach { child -> write(child, contentWidth, contentHeight, false, node.direction) }
1389
1449
  treeMeta[index * 2] = firstChild.toFloat()
1390
1450
  treeMeta[index * 2 + 1] = (cursor - firstChild).toFloat()
1391
1451
  }
1392
1452
  write(root, width, height, true, root.direction)
1393
- val frames = (if (autoRowsAware) {
1453
+ val frames = (if (explicitRowsAware) {
1454
+ CraftNative.layoutTreeV5(width.toFloat(), height.toFloat(), nodeData, treeMeta)
1455
+ } else if (autoRowsAware) {
1394
1456
  CraftNative.layoutTreeV4(width.toFloat(), height.toFloat(), nodeData, treeMeta)
1395
1457
  } else if (wrapReverseAware) {
1396
1458
  CraftNative.layoutTreeV3(width.toFloat(), height.toFloat(), nodeData, treeMeta)
@@ -1638,7 +1700,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1638
1700
  val flow = layout.rows.flatten()
1639
1701
  // Older runtimes return null for the optional symbol; the local
1640
1702
  // layout below remains the compatibility path in that case.
1641
- val sharedFrames = CraftNative.grid(
1703
+ val sharedFrames = if (gridRows == null) CraftNative.grid(
1642
1704
  contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap, rowGap,
1643
1705
  flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
1644
1706
  flow.map { it.style.width?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
@@ -1657,7 +1719,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1657
1719
  }.toFloatArray(),
1658
1720
  gridAutoRows?.resolve(contentHeight)?.toFloat(),
1659
1721
  alignContentCode(),
1660
- )
1722
+ ) else null
1661
1723
  if (sharedFrames != null) {
1662
1724
  for ((index, entry) in flow.withIndex()) {
1663
1725
  val offset = index * 4
@@ -1720,11 +1782,10 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1720
1782
  }
1721
1783
  for (entry in layout.absolute) {
1722
1784
  if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
1723
- val childWidth = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
1724
- val childHeight = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
1785
+ val (childWidth, childHeight) = absoluteSize(entry, contentWidth, contentHeight)
1725
1786
  val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - childWidth } ?: 0))
1726
1787
  val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - childHeight } ?: 0))
1727
- place(entry.view, x, y, x + max(0, childWidth), y + max(0, childHeight))
1788
+ place(entry.view, x, y, x + childWidth, y + childHeight)
1728
1789
  }
1729
1790
  onLayoutChanged?.invoke()
1730
1791
  return
@@ -1906,11 +1967,10 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1906
1967
  }
1907
1968
  for (entry in absolute) {
1908
1969
  if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
1909
- val widthValue = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
1910
- val heightValue = if (entry.style.top != null && entry.style.bottom != null) contentHeight - entry.style.top!! - entry.style.bottom!! else entry.height
1970
+ val (widthValue, heightValue) = absoluteSize(entry, contentWidth, contentHeight)
1911
1971
  val x = paddingLeft + (entry.style.left ?: (entry.style.right?.let { contentWidth - it - widthValue } ?: 0))
1912
1972
  val y = paddingTop + (entry.style.top ?: (entry.style.bottom?.let { contentHeight - it - heightValue } ?: 0))
1913
- place(entry.view, x, y, x + max(0, widthValue), y + max(0, heightValue))
1973
+ place(entry.view, x, y, x + widthValue, y + heightValue)
1914
1974
  }
1915
1975
  onLayoutChanged?.invoke()
1916
1976
  }
@@ -2015,6 +2075,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2015
2075
  private var stickyIndices = emptyList<Int>()
2016
2076
  private var scrollTargetKey: String? = null
2017
2077
  private var pendingScrollTarget: Pair<String, Boolean>? = null
2078
+ private var scrollListenerGeneration = 0
2018
2079
 
2019
2080
  init {
2020
2081
  vertical.isNestedScrollingEnabled = true
@@ -2160,6 +2221,21 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2160
2221
  horizontal.isNestedScrollingEnabled = value
2161
2222
  }
2162
2223
 
2224
+ fun scrollTo(x: Int?, y: Int?, animated: Boolean): Pair<Int, Int> {
2225
+ val scroller: View = if (horizontalMode == true) horizontal else vertical
2226
+ val maxX = max(0, content.width - horizontal.width)
2227
+ val maxY = max(0, content.height - vertical.height)
2228
+ val targetX = (x ?: scroller.scrollX).coerceIn(0, maxX)
2229
+ val targetY = (y ?: scroller.scrollY).coerceIn(0, maxY)
2230
+ if (animated) {
2231
+ if (scroller is HorizontalScrollView) scroller.smoothScrollTo(targetX, targetY)
2232
+ else (scroller as ScrollView).smoothScrollTo(targetX, targetY)
2233
+ } else {
2234
+ scroller.scrollTo(targetX, targetY)
2235
+ }
2236
+ return targetX to targetY
2237
+ }
2238
+
2163
2239
  fun setScrollEnabled(value: Boolean) {
2164
2240
  vertical.isEnabled = value
2165
2241
  horizontal.isEnabled = value
@@ -2286,7 +2362,10 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2286
2362
  return
2287
2363
  }
2288
2364
  val scrollY = vertical.scrollY
2289
- var pinnedBottom = 0
2365
+ // Keep sticky content below the ScrollView's authored top inset,
2366
+ // matching the native content viewport instead of pinning it to the
2367
+ // raw window edge.
2368
+ var pinnedBottom = content.paddingTop
2290
2369
  stickyIndices.forEachIndexed { position, index ->
2291
2370
  val header = content.getChildAt(index)
2292
2371
  if (header == null || header.visibility != View.VISIBLE || header.height <= 0) return@forEachIndexed
@@ -2362,6 +2441,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2362
2441
  keyboardDismissMode: String,
2363
2442
  dismissKeyboard: (() -> Unit)?,
2364
2443
  ) {
2444
+ val generation = ++scrollListenerGeneration
2365
2445
  fun attach(scroller: View) {
2366
2446
  var dismissed = false
2367
2447
  var moved = false
@@ -2370,7 +2450,7 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2370
2450
  var lastY = 0
2371
2451
 
2372
2452
  fun pollMomentum() {
2373
- if (!momentumActive) return
2453
+ if (generation != scrollListenerGeneration || !momentumActive) return
2374
2454
  val x = scroller.scrollX
2375
2455
  val y = scroller.scrollY
2376
2456
  if (x == lastX && y == lastY) {
@@ -2426,6 +2506,14 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2426
2506
  attach(vertical)
2427
2507
  attach(horizontal)
2428
2508
  }
2509
+
2510
+ fun clearScrollListeners() {
2511
+ scrollListenerGeneration += 1
2512
+ vertical.setOnScrollChangeListener(null)
2513
+ vertical.setOnTouchListener(null)
2514
+ horizontal.setOnScrollChangeListener(null)
2515
+ horizontal.setOnTouchListener(null)
2516
+ }
2429
2517
  }
2430
2518
 
2431
2519
  private class CraftNativeFlexSpacer(context: Context) : View(context)
@@ -2471,7 +2559,8 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2471
2559
  private var renderedVisible = false
2472
2560
  private var dismissSuppressedByRequest = false
2473
2561
  private var animationGeneration = 0
2474
- var onRequestClose: (() -> Unit)? = null
2562
+ var focusToRestore: View? = null
2563
+ var onRequestClose: ((String) -> Unit)? = null
2475
2564
 
2476
2565
  var transparent: Boolean = false
2477
2566
  set(value) {
@@ -2483,7 +2572,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2483
2572
  isClickable = true
2484
2573
  blocker.isClickable = true
2485
2574
  blocker.setOnTouchListener { _, event ->
2486
- if (event.action == MotionEvent.ACTION_UP) requestClose()
2575
+ if (event.action == MotionEvent.ACTION_UP) requestClose(reason = "backdrop")
2487
2576
  true
2488
2577
  }
2489
2578
  blocker.setBackgroundColor(Color.argb(82, 0, 0, 0))
@@ -2491,12 +2580,12 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2491
2580
  addView(content, LayoutParams(-1, -1))
2492
2581
  }
2493
2582
 
2494
- fun requestClose() {
2583
+ fun requestClose(reason: String = "backdrop") {
2495
2584
  val handler = onRequestClose ?: return
2496
2585
  dismissSuppressedByRequest = true
2497
2586
  isClickable = false
2498
2587
  blocker.isEnabled = false
2499
- handler()
2588
+ handler(reason)
2500
2589
  }
2501
2590
 
2502
2591
  fun consumeDismissSuppression(): Boolean {
@@ -2693,6 +2782,11 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2693
2782
  return JSONObject().put("value", value).put("iso", iso).put("timestamp", current.timeInMillis)
2694
2783
  }
2695
2784
 
2785
+ fun resetForTeardown() {
2786
+ suppressChanges = true
2787
+ onValueChanged = null
2788
+ }
2789
+
2696
2790
  private fun changed() {
2697
2791
  if (suppressChanges) return
2698
2792
  // A date-only picker has no time half to snap, but its change is
@@ -2983,6 +3077,12 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2983
3077
  fun stop() {
2984
3078
  // Released with its screen: nothing more is told to it.
2985
3079
  emit = null
3080
+ // VideoView otherwise keeps these listener objects until its media
3081
+ // player is replaced. Clear them before stopping so an asynchronous
3082
+ // prepare/completion/error callback cannot outlive the native node.
3083
+ setOnPreparedListener(null)
3084
+ setOnCompletionListener(null)
3085
+ setOnErrorListener(null)
2986
3086
  progressHandler.removeCallbacks(progressTick)
2987
3087
  stopPlayback()
2988
3088
  setPlaying(false)
@@ -2995,6 +3095,8 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2995
3095
  pictureInPicture = false
2996
3096
  onPictureInPictureChanged?.invoke()
2997
3097
  }
3098
+ onPictureInPictureChanged = null
3099
+ handlers = JSONObject()
2998
3100
  }
2999
3101
 
3000
3102
  private fun applyScalingMode() {
@@ -3525,6 +3627,7 @@ class MainActivity : AppCompatActivity() {
3525
3627
  private class Screen(
3526
3628
  val route: String?,
3527
3629
  val params: JSONObject,
3630
+ val props: JSONObject,
3528
3631
  val view: CraftNativeFlexLayout,
3529
3632
  val isolate: JavaScriptIsolate,
3530
3633
  val port: MessagePort?,
@@ -3549,6 +3652,7 @@ class MainActivity : AppCompatActivity() {
3549
3652
  val drafts = mutableMapOf<String, String>()
3550
3653
  val inputIdentities = mutableMapOf<EditText, String>()
3551
3654
  val suppressedTextChanges = mutableSetOf<EditText>()
3655
+ val inputWatchers = mutableMapOf<EditText, TextWatcher>()
3552
3656
  /**
3553
3657
  * The texts a field sent the screen that its `value` has not come
3554
3658
  * back as yet, oldest first. Typing faster than the round trip makes
@@ -3562,6 +3666,7 @@ class MainActivity : AppCompatActivity() {
3562
3666
  // the original tree path without manufacturing a detached view.
3563
3667
  val nodeViews = mutableMapOf<String, View>()
3564
3668
  val nodePaths = mutableMapOf<String, String>()
3669
+ val protocolIds = mutableMapOf<View, String>()
3565
3670
  val handlers = mutableMapOf<View, String>()
3566
3671
  val linkTargets = mutableMapOf<View, String>()
3567
3672
  val pressInHandlers = mutableMapOf<View, String>()
@@ -3709,8 +3814,6 @@ class MainActivity : AppCompatActivity() {
3709
3814
  private val imageJobs = mutableMapOf<ImageView, Future<*>>()
3710
3815
  private val imageErrors = mutableMapOf<ImageView, String>()
3711
3816
  private val inputHintDefaults = mutableMapOf<EditText, ColorStateList>()
3712
- /** Fields that already carry their text watcher. */
3713
- private val watchedInputs: MutableSet<EditText> = java.util.Collections.newSetFromMap(WeakHashMap())
3714
3817
  private val inputHighlightDefaults = mutableMapOf<EditText, Int>()
3715
3818
  private val inputPaddingDefaults = mutableMapOf<EditText, IntArray>()
3716
3819
  private val defaultBackgrounds = mutableMapOf<View, Drawable?>()
@@ -4060,7 +4163,7 @@ class MainActivity : AppCompatActivity() {
4060
4163
  * page on the app's site in the in-app browser. Another site's address
4061
4164
  * opens in the browser too.
4062
4165
  */
4063
- private fun navigateOpen(target: String, fromLink: Boolean = false, replace: Boolean = false) {
4166
+ private fun navigateOpen(target: String, fromLink: Boolean = false, replace: Boolean = false, props: JSONObject = JSONObject()) {
4064
4167
  val path = routes.appPath(target, urlSchemes)
4065
4168
  if (path == null) {
4066
4169
  openExternal(target)
@@ -4072,7 +4175,7 @@ class MainActivity : AppCompatActivity() {
4072
4175
  val removed = stack.removeAt(stack.lastIndex)
4073
4176
  val previous = stack.lastOrNull()
4074
4177
  closeScreen(removed) {
4075
- if (screens === stack && stack.lastOrNull() === previous) navigateOpen(path, fromLink)
4178
+ if (screens === stack && stack.lastOrNull() === previous) navigateOpen(path, fromLink, props = props)
4076
4179
  }
4077
4180
  return
4078
4181
  }
@@ -4081,8 +4184,8 @@ class MainActivity : AppCompatActivity() {
4081
4184
  selectTab(routes.tabs.indexOf(tab))
4082
4185
  popToRoot()
4083
4186
  val root = screens.firstOrNull()
4084
- if (root != null && match != null && match.params.length() > 0) {
4085
- send(root, "ROUTE_PARAMS", JSONObject().put("params", match.params))
4187
+ if (root != null && match != null && (match.params.length() > 0 || props.length() > 0)) {
4188
+ send(root, "ROUTE_PARAMS", JSONObject().put("params", match.params).put("props", props))
4086
4189
  }
4087
4190
  return
4088
4191
  }
@@ -4104,12 +4207,12 @@ class MainActivity : AppCompatActivity() {
4104
4207
  closeScreens(removed) {
4105
4208
  if (screens === stack && stack.lastOrNull() === existing) {
4106
4209
  show(existing)
4107
- send(existing, "ROUTE_PARAMS", JSONObject().put("params", match.params))
4210
+ send(existing, "ROUTE_PARAMS", JSONObject().put("params", match.params).put("props", props))
4108
4211
  }
4109
4212
  }
4110
4213
  return
4111
4214
  }
4112
- openScreen(match.screen, match.params)?.path = match.path
4215
+ openScreen(match.screen, match.params, props = props)?.path = match.path
4113
4216
  }
4114
4217
 
4115
4218
  /** A page the app does not draw, in a Custom Tab over the app. */
@@ -4117,7 +4220,7 @@ class MainActivity : AppCompatActivity() {
4117
4220
  services.perform("Browser", "open", JSONArray().put(url), {})
4118
4221
  }
4119
4222
 
4120
- private fun openScreen(route: String?, params: JSONObject = JSONObject(), headless: Boolean = false, showImmediately: Boolean = true): Screen? {
4223
+ private fun openScreen(route: String?, params: JSONObject = JSONObject(), props: JSONObject = JSONObject(), headless: Boolean = false, showImmediately: Boolean = true): Screen? {
4121
4224
  if (showImmediately && !headless) interruptTabSwipe()
4122
4225
  val engine = sandbox ?: return null
4123
4226
  val isolate = engine.createIsolate()
@@ -4145,7 +4248,7 @@ class MainActivity : AppCompatActivity() {
4145
4248
  })
4146
4249
  null
4147
4250
  }
4148
- val activeScreen = Screen(route, params, view, isolate, port, headless)
4251
+ val activeScreen = Screen(route, params, props, view, isolate, port, headless)
4149
4252
  activeScreen.view.onLayoutChanged = {
4150
4253
  if (activeScreen.active) emitLayoutEvents(activeScreen)
4151
4254
  }
@@ -4160,6 +4263,7 @@ class MainActivity : AppCompatActivity() {
4160
4263
  val routePrelude = """
4161
4264
  globalThis.__stxNativeRoute = ${route?.let { JSONObject.quote(it) } ?: "undefined"};
4162
4265
  globalThis.__stxNativeParams = ${params};
4266
+ globalThis.__stxNativeProps = ${props};
4163
4267
  """.trimIndent()
4164
4268
  val audioPrelude = """
4165
4269
  (function() {
@@ -4367,6 +4471,12 @@ class MainActivity : AppCompatActivity() {
4367
4471
  load.addListener({
4368
4472
  try {
4369
4473
  load.get()
4474
+ // The STX runtime may install its browser-shaped
4475
+ // craft.dialog after the host prelude. Re-assert the
4476
+ // native capability once the bundle has finished.
4477
+ isolate.evaluateJavaScriptAsync(
4478
+ "if (typeof globalThis.__craftInstallNativeDialog === 'function') globalThis.__craftInstallNativeDialog();"
4479
+ ).get()
4370
4480
  if (activeScreen.headless) {
4371
4481
  CraftNativeBackground.foreground = { event, id, done ->
4372
4482
  val app = appScreen
@@ -4518,6 +4628,10 @@ class MainActivity : AppCompatActivity() {
4518
4628
  /** The bridge's capability names: Craft's core set and this host's services. */
4519
4629
  private fun capabilityNames(): JSONArray = capabilities.names().apply {
4520
4630
  services.names().forEach { name -> put(name) }
4631
+ put("viewFocus")
4632
+ put("viewMeasure")
4633
+ put("viewScroll")
4634
+ put("viewReparent")
4521
4635
  }
4522
4636
 
4523
4637
  private fun appVersion(): Pair<String, String> = runCatching {
@@ -4570,6 +4684,7 @@ class MainActivity : AppCompatActivity() {
4570
4684
  ViewCompat.requestApplyInsets(root)
4571
4685
  refreshInputDefaultsForTraitChange()
4572
4686
  chrome?.refreshColors()
4687
+ mapViews().forEach { it.refreshThemeDefaults() }
4573
4688
  // Every screen follows light and dark, the ones in other tabs and
4574
4689
  // under the one on display too, and the app script hears it.
4575
4690
  val appearance = appearance()
@@ -4589,6 +4704,10 @@ class MainActivity : AppCompatActivity() {
4589
4704
  else -> null
4590
4705
  }
4591
4706
  }.forEach { it.refreshThemeDefaults() }
4707
+ // Appearance/configuration changes can arrive while this screen is
4708
+ // retained in another tab. Keep its SafeAreaView measured with the
4709
+ // current window insets before the next interactive transition.
4710
+ updateSafeAreaInsets(screen)
4592
4711
  updateSafeAreaViews()
4593
4712
  }
4594
4713
 
@@ -4697,7 +4816,9 @@ class MainActivity : AppCompatActivity() {
4697
4816
  .put("message", "mutation batch could not be applied")
4698
4817
  .put("version", CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION))
4699
4818
  }
4700
- "NAVIGATE_OPEN" -> payload.optString("path").takeIf { it.isNotBlank() }?.let(::navigateOpen)
4819
+ "NAVIGATE_OPEN" -> payload.optString("path").takeIf { it.isNotBlank() }?.let {
4820
+ navigateOpen(it, props = payload.optJSONObject("props") ?: JSONObject())
4821
+ }
4701
4822
  "NAVIGATION_SET_OPTIONS" -> {
4702
4823
  // The app script has no navigation bar of its own. Screen
4703
4824
  // options remain valid while that screen is off-stack, so
@@ -4913,6 +5034,7 @@ class MainActivity : AppCompatActivity() {
4913
5034
  screen.controls.clear()
4914
5035
  screen.nodeViews.clear()
4915
5036
  screen.nodePaths.clear()
5037
+ screen.protocolIds.clear()
4916
5038
  screen.handlers.clear()
4917
5039
  screen.linkTargets.clear()
4918
5040
  screen.flatListRows.clear()
@@ -4934,15 +5056,18 @@ class MainActivity : AppCompatActivity() {
4934
5056
  val children = node.optJSONArray("children") ?: JSONArray()
4935
5057
  val protocolId = node.optString("id").takeIf { it.isNotBlank() }
4936
5058
  val explicitKey = authoredKey(node, props)
4937
- // Descendants are scoped to this retained identity, not its sibling
4938
- // position. Moving a keyed parent must retain its nested inputs too.
4939
- // A test selector may intentionally identify several list rows. The
4940
- // compiler's unique protocol ID must win over that legacy fallback.
5059
+ // Authored keys retain conditional replacements and scope their
5060
+ // descendants. A protocol alias additionally finds the same live
5061
+ // view when a popup moves to another parent.
4941
5062
  val identity = explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
4942
5063
  ?: protocolId?.let { "id:$it" }
4943
5064
  ?: props.optString("testID").takeIf { it.isNotBlank() }?.let { "${path.substringBeforeLast('.', "")}/$it" }
4944
5065
  ?: path
4945
- val previous = previousControls[identity]
5066
+ val keyedPrevious = previousControls[identity]?.takeIf { candidate ->
5067
+ val previousId = screen.protocolIds[candidate]
5068
+ previousId == null || previousId == protocolId || !screen.mutations.containsNode(previousId)
5069
+ }
5070
+ val previous = protocolId?.let { previousControls["id:$it"] } ?: keyedPrevious
4946
5071
  val reusableInput = previous as? EditText
4947
5072
  val control = when (type) {
4948
5073
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
@@ -4955,6 +5080,11 @@ class MainActivity : AppCompatActivity() {
4955
5080
  else -> null
4956
5081
  }
4957
5082
  configureText(this, childText(children), style)
5083
+ val selectable = craftNativeBoolean(props.opt("selectable"))
5084
+ setTextIsSelectable(selectable)
5085
+ isFocusable = selectable
5086
+ isFocusableInTouchMode = selectable
5087
+ isLongClickable = selectable
4958
5088
  }
4959
5089
  "StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
4960
5090
  screen.statusBarStyle = craftNativeOptionalString(props.opt("barStyle"))
@@ -5163,21 +5293,27 @@ class MainActivity : AppCompatActivity() {
5163
5293
  if (text.toString() == keptText && keptStart >= 0 && (selectionStart != keptStart || selectionEnd != keptEnd)) {
5164
5294
  setSelection(keptStart.coerceAtMost(keptText.length), keptEnd.coerceAtMost(keptText.length))
5165
5295
  }
5166
- // One watcher per field, however often it is reused.
5167
- if (watchedInputs.add(this)) addTextChangedListener(object : TextWatcher {
5168
- override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
5169
- override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
5170
- val updated = s?.toString().orEmpty()
5171
- (screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
5172
- if (screen.suppressedTextChanges.remove(this@apply)) return
5173
- val current = screen.handlers[this@apply] ?: return
5174
- screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
5175
- send(screen, "EVENT", JSONObject()
5176
- .put("handlerName", current)
5177
- .put("nativeEvent", JSONObject().put("text", updated)))
5296
+ // One watcher per field, however often it is reused. Keep the
5297
+ // instance so releaseViewState can detach the closure when a
5298
+ // row or screen goes away.
5299
+ if (screen.inputWatchers[this] == null) {
5300
+ val watcher = object : TextWatcher {
5301
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
5302
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
5303
+ val updated = s?.toString().orEmpty()
5304
+ (screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
5305
+ if (screen.suppressedTextChanges.remove(this@apply)) return
5306
+ val current = screen.handlers[this@apply] ?: return
5307
+ screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
5308
+ send(screen, "EVENT", JSONObject()
5309
+ .put("handlerName", current)
5310
+ .put("nativeEvent", JSONObject().put("text", updated)))
5311
+ }
5312
+ override fun afterTextChanged(s: Editable?) {}
5178
5313
  }
5179
- override fun afterTextChanged(s: Editable?) {}
5180
- })
5314
+ screen.inputWatchers[this] = watcher
5315
+ addTextChangedListener(watcher)
5316
+ }
5181
5317
  }
5182
5318
  "Picker" -> (previous as? Spinner ?: Spinner(this)).apply {
5183
5319
  val itemOptions = props.optJSONArray("items")?.let { items ->
@@ -5240,6 +5376,7 @@ class MainActivity : AppCompatActivity() {
5240
5376
  }
5241
5377
  }
5242
5378
  "Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
5379
+ val focusBeforeContent = screen.view.findFocus()
5243
5380
  transparent = craftNativeBoolean(props.opt("transparent"))
5244
5381
  content.configure(style, resources.displayMetrics.density)
5245
5382
  reconcileChildren(screen, content, children, identity, next, style, previousControls)
@@ -5255,14 +5392,24 @@ class MainActivity : AppCompatActivity() {
5255
5392
  }
5256
5393
  val wasVisible = screen.modalVisibility[this]
5257
5394
  screen.modalVisibility[this] = visible
5395
+ if (visible && wasVisible != true && focusBeforeContent != null && focusBeforeContent !is CraftNativeModalView) {
5396
+ focusToRestore = focusBeforeContent
5397
+ }
5258
5398
  val requestClose = craftNativeOptionalString(events.opt("onRequestClose")).orEmpty()
5259
5399
  if (requestClose.isNotBlank()) screen.modalCloseHandlers[this] = requestClose else screen.modalCloseHandlers.remove(this)
5260
5400
  onRequestClose = if (requestClose.isNotBlank()) {
5261
- { send(screen, "EVENT", JSONObject().put("handlerName", requestClose).put("nativeEvent", JSONObject())) }
5401
+ { reason -> send(screen, "EVENT", JSONObject().put("handlerName", requestClose).put("nativeEvent", JSONObject().put("reason", reason))) }
5262
5402
  } else {
5263
5403
  null
5264
5404
  }
5265
5405
  setVisible(visible, craftNativeFiniteFloat(style, "opacity", 1f), craftNativeOptionalString(props.opt("animationType")))
5406
+ if (!visible && wasVisible == true) {
5407
+ val restore = focusToRestore
5408
+ focusToRestore = null
5409
+ restore?.post {
5410
+ if (restore.isAttachedToWindow) restore.requestFocus()
5411
+ }
5412
+ }
5266
5413
  if (visible) clearDismissSuppression()
5267
5414
  val suppressDismiss = !visible && consumeDismissSuppression()
5268
5415
  if (wasVisible != visible && (visible || wasVisible == true)) {
@@ -5301,10 +5448,10 @@ class MainActivity : AppCompatActivity() {
5301
5448
  setOnCheckedChangeListener(null)
5302
5449
  val requestedValue = props.opt("value")
5303
5450
  val requestedChecked = props.opt("checked")
5304
- isChecked = when {
5305
- requestedValue != null && requestedValue != JSONObject.NULL -> requestedValue as? Boolean ?: false
5306
- requestedChecked is Boolean -> requestedChecked
5307
- else -> false
5451
+ if (requestedValue != null && requestedValue != JSONObject.NULL) {
5452
+ isChecked = requestedValue as? Boolean ?: false
5453
+ } else if (requestedChecked is Boolean) {
5454
+ isChecked = requestedChecked
5308
5455
  }
5309
5456
  isEnabled = !craftNativeBoolean(props.opt("disabled"))
5310
5457
  props.optJSONObject("trackColor")?.let { track ->
@@ -5755,6 +5902,10 @@ class MainActivity : AppCompatActivity() {
5755
5902
  screen.nodePaths[it] = path
5756
5903
  }
5757
5904
  next[identity] = control
5905
+ protocolId?.let {
5906
+ next["id:$it"] = control
5907
+ screen.protocolIds[control] = it
5908
+ }
5758
5909
  applyViewStyle(control, style)
5759
5910
  if (type == "ActivityIndicator") {
5760
5911
  val animating = craftNativeBoolean(props.opt("animating"), true)
@@ -5916,6 +6067,9 @@ class MainActivity : AppCompatActivity() {
5916
6067
  list.onLayoutChanged = {
5917
6068
  if (screen.active) emitLayoutEvents(screen)
5918
6069
  }
6070
+ list.onContentSizeChanged = {
6071
+ if (screen.active) CraftNativeFlexLayout.invalidateMeasurementAncestors(list.parent)
6072
+ }
5919
6073
  val nodes = (0 until children.length()).mapNotNull { children.optJSONObject(it) }
5920
6074
  nodes.forEach { registerFlatListOwnership(screen, it, owner) }
5921
6075
  list.onScrollEvent = craftNativeEventName(events, "onScroll").takeIf { it.isNotBlank() }?.let { handler ->
@@ -5955,6 +6109,10 @@ class MainActivity : AppCompatActivity() {
5955
6109
  val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
5956
6110
  releaseViewState(screen, controls.values.toList(), preservingInputDrafts = true)
5957
6111
  },
6112
+ removeItem = { itemIdentity ->
6113
+ val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
6114
+ releaseViewState(screen, controls.values.toList(), preservingInputDrafts = false)
6115
+ },
5958
6116
  endReached = craftNativeEventName(events, "onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
5959
6117
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5960
6118
  },
@@ -6411,6 +6569,13 @@ class MainActivity : AppCompatActivity() {
6411
6569
  host == "::1" || host.endsWith(".localhost")
6412
6570
  }
6413
6571
 
6572
+ /** Resolve app paths against the configured site; bare names stay assets. */
6573
+ private fun imageURI(uri: String): String {
6574
+ if (!uri.startsWith("/")) return uri
6575
+ val base = config.optString("devServerURL").takeIf { it.isNotBlank() } ?: return uri
6576
+ return runCatching { URI(base).resolve(uri).toString() }.getOrDefault(uri)
6577
+ }
6578
+
6414
6579
  private fun imageUri(source: Any?): String = when (source) {
6415
6580
  is String -> source
6416
6581
  is JSONObject -> craftNativeOptionalString(source.opt("uri")).orEmpty()
@@ -6439,6 +6604,7 @@ class MainActivity : AppCompatActivity() {
6439
6604
 
6440
6605
  private fun updateImage(screen: Screen, view: ImageView, source: Any?, defaultSource: Any? = null) {
6441
6606
  val uri = imageUri(source)
6607
+ val requestUri = imageURI(uri)
6442
6608
  val headers = imageHeaders(source)
6443
6609
  val requestKey = imageRequestKey(uri, headers)
6444
6610
  if (uri.isBlank()) {
@@ -6472,7 +6638,7 @@ class MainActivity : AppCompatActivity() {
6472
6638
  } else imageFailure(screen, view, uri, "Image data is invalid")
6473
6639
  return
6474
6640
  }
6475
- if (!uri.contains(':')) {
6641
+ if (!uri.startsWith("/") && !uri.contains(':')) {
6476
6642
  val bitmap = runCatching { assets.open(uri).use { BitmapFactory.decodeStream(it) } }.getOrNull()
6477
6643
  if (bitmap != null) {
6478
6644
  view.setImageBitmap(bitmap)
@@ -6482,22 +6648,23 @@ class MainActivity : AppCompatActivity() {
6482
6648
  } else imageFailure(screen, view, uri, "Bundled image was not found")
6483
6649
  return
6484
6650
  }
6485
- if (!loadsImage(Uri.parse(uri))) {
6486
- imageFailure(screen, view, uri, "Unsupported image source")
6651
+ if (!loadsImage(Uri.parse(requestUri))) {
6652
+ if (uri.startsWith("/")) imageFailure(screen, view, uri, "Relative image source requires devServerURL")
6653
+ else imageFailure(screen, view, uri, "Unsupported image source")
6487
6654
  return
6488
6655
  }
6489
6656
  imageJobs[view] = imageExecutor.submit {
6490
6657
  val bitmap = runCatching {
6491
- val publicRequest = headers.isEmpty() && Uri.parse(uri).userInfo.isNullOrEmpty()
6658
+ val publicRequest = headers.isEmpty() && Uri.parse(requestUri).userInfo.isNullOrEmpty()
6492
6659
  if (publicRequest) {
6493
- val bytes = imageCache.read(uri)
6660
+ val bytes = imageCache.read(requestUri)
6494
6661
  if (bytes != null) {
6495
6662
  val saved = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
6496
6663
  if (saved != null) return@runCatching saved
6497
- imageCache.remove(uri)
6664
+ imageCache.remove(requestUri)
6498
6665
  }
6499
6666
  }
6500
- val connection = (URL(uri).openConnection() as HttpURLConnection).apply {
6667
+ val connection = (URL(requestUri).openConnection() as HttpURLConnection).apply {
6501
6668
  connectTimeout = 10_000
6502
6669
  readTimeout = 10_000
6503
6670
  // This loader owns its bounded public cache. Do not let
@@ -6515,8 +6682,8 @@ class MainActivity : AppCompatActivity() {
6515
6682
  if (downloaded != null && policy != null) {
6516
6683
  // Encode only after a complete decode, then commit
6517
6684
  // before onLoad so a cold offline reopen is safe.
6518
- imageCache.write(uri, policy) { downloaded.compress(Bitmap.CompressFormat.PNG, 100, it) }
6519
- } else imageCache.remove(uri)
6685
+ imageCache.write(requestUri, policy) { downloaded.compress(Bitmap.CompressFormat.PNG, 100, it) }
6686
+ } else imageCache.remove(requestUri)
6520
6687
  }
6521
6688
  downloaded
6522
6689
  } finally { connection.disconnect() }
@@ -6593,8 +6760,17 @@ class MainActivity : AppCompatActivity() {
6593
6760
  else -> null
6594
6761
  }
6595
6762
  view.tooltipText = craftNativeOptionalString(props.opt("accessibilityHint"))
6763
+ val hasLinkTarget = listOf("href", "to").any { key ->
6764
+ !craftNativeOptionalString(props.opt(key)).isNullOrBlank()
6765
+ }
6766
+ val linkedControl = type in setOf("Link", "TouchableOpacity", "TouchableHighlight", "Pressable") && hasLinkTarget
6596
6767
  val role = craftNativeOptionalString(props.opt("accessibilityRole")) ?: run {
6597
- if (type == "Icon") "image" else type.lowercase()
6768
+ when {
6769
+ linkedControl -> "link"
6770
+ type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") -> "button"
6771
+ type == "Icon" -> "image"
6772
+ else -> type.lowercase()
6773
+ }
6598
6774
  }
6599
6775
  val state = props.optJSONObject("accessibilityState")
6600
6776
  val disabled = craftNativeBoolean(props.opt("disabled")) || craftNativeBoolean(state?.opt("disabled"))
@@ -6746,15 +6922,18 @@ class MainActivity : AppCompatActivity() {
6746
6922
  capabilityHandler.postDelayed(timeout, CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS)
6747
6923
  }
6748
6924
  val module = payload.optString("module")
6749
- val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (services.handles(module)) {
6750
- { completion -> services.perform(module, payload.optString("method"), args, completion) }
6925
+ val method = payload.optString("method")
6926
+ val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") {
6927
+ { completion -> performView(screen, method, args, completion) }
6928
+ } else if (services.handles(module)) {
6929
+ { completion -> services.perform(module, method, args, completion) }
6751
6930
  } else {
6752
6931
  { completion ->
6753
6932
  capabilities.perform(
6754
6933
  token,
6755
6934
  payload.optInt("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION),
6756
6935
  module,
6757
- payload.optString("method"),
6936
+ method,
6758
6937
  args,
6759
6938
  completion,
6760
6939
  )
@@ -6772,6 +6951,97 @@ class MainActivity : AppCompatActivity() {
6772
6951
  }
6773
6952
  }
6774
6953
 
6954
+ /** View operations are screen-owned: node ids only exist while a route is mounted. */
6955
+ private fun performView(
6956
+ screen: Screen,
6957
+ method: String,
6958
+ args: JSONArray,
6959
+ completion: (CraftNativeCapabilityResult) -> Unit,
6960
+ ) {
6961
+ val options = args.optJSONObject(0)
6962
+ val nodeId = options?.optString("nodeId")?.takeIf { it.isNotBlank() } ?: args.optString(0)
6963
+ if (nodeId.isBlank()) {
6964
+ completion(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "View.$method needs a nodeId"))
6965
+ return
6966
+ }
6967
+ val target = screen.nodeViews[nodeId]
6968
+ if (target == null) {
6969
+ completion(CraftNativeCapabilityResult(code = "VIEW_NOT_FOUND", message = "View.$method could not find mounted node $nodeId"))
6970
+ return
6971
+ }
6972
+ if (!target.isAttachedToWindow) {
6973
+ completion(CraftNativeCapabilityResult(code = "VIEW_NOT_MOUNTED", message = "View.$method requires a mounted node"))
6974
+ return
6975
+ }
6976
+ when (method) {
6977
+ "focus" -> when (options?.optString("mode") ?: args.optString(1, "keyboard")) {
6978
+ "keyboard" -> if (target is EditText) {
6979
+ if (!target.requestFocus()) {
6980
+ completion(CraftNativeCapabilityResult(code = "FOCUS_FAILED", message = "The mounted view refused keyboard focus"))
6981
+ } else {
6982
+ target.post {
6983
+ (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
6984
+ ?.showSoftInput(target, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
6985
+ }
6986
+ completion(CraftNativeCapabilityResult(data = true))
6987
+ }
6988
+ } else completion(CraftNativeCapabilityResult(code = "UNSUPPORTED_TARGET", message = "Keyboard focus is supported only for text inputs"))
6989
+ "accessibility" -> {
6990
+ target.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_FOCUSED)
6991
+ completion(CraftNativeCapabilityResult(data = true))
6992
+ }
6993
+ else -> completion(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "View.focus mode must be keyboard or accessibility"))
6994
+ }
6995
+ "measure" -> {
6996
+ screen.view.requestLayout()
6997
+ screen.view.measure(
6998
+ View.MeasureSpec.makeMeasureSpec(screen.view.width, View.MeasureSpec.EXACTLY),
6999
+ View.MeasureSpec.makeMeasureSpec(screen.view.height, View.MeasureSpec.EXACTLY),
7000
+ )
7001
+ val targetLocation = IntArray(2)
7002
+ val viewportLocation = IntArray(2)
7003
+ target.getLocationOnScreen(targetLocation)
7004
+ screen.view.getLocationOnScreen(viewportLocation)
7005
+ val density = resources.displayMetrics.density.coerceAtLeast(0.1f)
7006
+ completion(CraftNativeCapabilityResult(data = JSONObject()
7007
+ .put("x", (targetLocation[0] - viewportLocation[0]) / density)
7008
+ .put("y", (targetLocation[1] - viewportLocation[1]) / density)
7009
+ .put("width", target.width / density)
7010
+ .put("height", target.height / density)
7011
+ .put("viewportWidth", screen.view.width / density)
7012
+ .put("viewportHeight", screen.view.height / density)
7013
+ .put("unit", "dp")
7014
+ .put("coordinateSpace", "screenViewport")))
7015
+ }
7016
+ "scrollTo" -> {
7017
+ val scroll = target as? CraftNativeScrollView
7018
+ if (scroll == null) {
7019
+ completion(CraftNativeCapabilityResult(code = "UNSUPPORTED_TARGET", message = "View.scrollTo is supported only for scroll views"))
7020
+ return
7021
+ }
7022
+ val rawX = options?.opt("x")
7023
+ val rawY = options?.opt("y")
7024
+ val x = (rawX as? Number)?.toDouble()?.takeIf { it.isFinite() }
7025
+ val y = (rawY as? Number)?.toDouble()?.takeIf { it.isFinite() }
7026
+ if ((options?.has("x") == true && x == null) || (options?.has("y") == true && y == null)) {
7027
+ completion(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "View.scrollTo coordinates must be finite numbers"))
7028
+ return
7029
+ }
7030
+ val density = resources.displayMetrics.density.coerceAtLeast(0.1f)
7031
+ val position = scroll.scrollTo(
7032
+ x?.times(density)?.roundToInt(),
7033
+ y?.times(density)?.roundToInt(),
7034
+ options?.optBoolean("animated", false) == true,
7035
+ )
7036
+ completion(CraftNativeCapabilityResult(data = JSONObject()
7037
+ .put("x", position.first / density)
7038
+ .put("y", position.second / density)
7039
+ .put("unit", "dp")))
7040
+ }
7041
+ else -> completion(CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported View operation"))
7042
+ }
7043
+ }
7044
+
6775
7045
  internal fun performImmediateCapability(module: String, method: String, args: JSONArray): CraftNativeCapabilityResult =
6776
7046
  when (module to method) {
6777
7047
  "Device" to "getInfo" -> CraftNativeCapabilityResult(data = JSONObject()
@@ -7172,7 +7442,7 @@ class MainActivity : AppCompatActivity() {
7172
7442
  preservingInputDrafts: Boolean,
7173
7443
  ) {
7174
7444
  val owner = screen.flatListOwnerByView.remove(list)
7175
- list.discardAll()
7445
+ list.resetForTeardown()
7176
7446
  screen.flatListRows.remove(list)?.values?.forEach {
7177
7447
  releaseViewState(screen, it.values, preservingInputDrafts)
7178
7448
  }
@@ -7196,13 +7466,39 @@ class MainActivity : AppCompatActivity() {
7196
7466
  releaseFlatList(screen, it, preservingInputDrafts)
7197
7467
  }
7198
7468
  released.filterIsInstance<CraftNativeModalView>().forEach { it.resetForTeardown() }
7469
+ released.filterIsInstance<CraftNativeDatePickerView>().forEach { it.resetForTeardown() }
7470
+ released.filterIsInstance<CraftNativeRefreshableFlatList>().forEach { it.resetForTeardown() }
7199
7471
  released.filterIsInstance<CraftNativeVideoView>().forEach { it.stop() }
7200
7472
  released.filterIsInstance<CraftNativeMapView>().forEach { it.destroy() }
7201
7473
  released.filterIsInstance<CraftNativeFlexLayout>().forEach { it.onLayoutChanged = null }
7202
7474
  released.filterIsInstance<CraftNativeKeyboardAvoidingView>().forEach { it.content.onLayoutChanged = null }
7203
- released.filterIsInstance<CraftNativeScrollView>().forEach { it.setRefreshHandler(null, false) }
7475
+ released.filterIsInstance<CraftNativeScrollView>().forEach {
7476
+ it.setRefreshHandler(null, false)
7477
+ it.clearScrollListeners()
7478
+ }
7204
7479
  releaseImages(released)
7480
+ // Clearing the handler maps is not enough: Android Views retain the
7481
+ // listener objects themselves. Detach every interaction callback from
7482
+ // released/recycled rows so a later identity cannot inherit a closure
7483
+ // that still captures the previous row's screen state.
7484
+ released.forEach {
7485
+ // AdapterView (including Spinner, our native Picker) rejects
7486
+ // setOnClickListener altogether. Its selection callback is
7487
+ // detached below; ordinary Views still need their click target
7488
+ // cleared here.
7489
+ if (it !is android.widget.AdapterView<*>) it.setOnClickListener(null)
7490
+ it.setOnTouchListener(null)
7491
+ it.setOnLongClickListener(null)
7492
+ it.setOnFocusChangeListener(null)
7493
+ it.isClickable = false
7494
+ it.isLongClickable = false
7495
+ }
7496
+ released.filterIsInstance<android.widget.CompoundButton>().forEach { it.setOnCheckedChangeListener(null) }
7497
+ released.filterIsInstance<SeekBar>().forEach { it.setOnSeekBarChangeListener(null) }
7498
+ released.filterIsInstance<Spinner>().forEach { it.onItemSelectedListener = null }
7499
+ released.filterIsInstance<EditText>().forEach { it.setOnEditorActionListener(null) }
7205
7500
  released.filterIsInstance<EditText>().forEach {
7501
+ screen.inputWatchers.remove(it)?.let(it::removeTextChangedListener)
7206
7502
  inputHintDefaults.remove(it)
7207
7503
  inputHighlightDefaults.remove(it)
7208
7504
  inputPaddingDefaults.remove(it)
@@ -7213,6 +7509,7 @@ class MainActivity : AppCompatActivity() {
7213
7509
  }
7214
7510
  released.forEach(defaultBackgrounds::remove)
7215
7511
  screen.linkTargets.keys.removeAll(released)
7512
+ screen.protocolIds.keys.removeAll(released)
7216
7513
  screen.handlers.keys.removeAll(released)
7217
7514
  screen.pressInHandlers.keys.removeAll(released)
7218
7515
  screen.pressOutHandlers.keys.removeAll(released)
@@ -7270,9 +7567,12 @@ class MainActivity : AppCompatActivity() {
7270
7567
  screen.pendingCapabilities.forEach(capabilities::cancel)
7271
7568
  screen.pendingCapabilities.clear()
7272
7569
  releaseViewState(screen, retainedViews(screen).toList())
7570
+ screen.inputWatchers.forEach { (input, watcher) -> input.removeTextChangedListener(watcher) }
7571
+ screen.inputWatchers.clear()
7273
7572
  screen.controls.clear()
7274
7573
  screen.nodeViews.clear()
7275
7574
  screen.nodePaths.clear()
7575
+ screen.protocolIds.clear()
7276
7576
  screen.handlers.clear()
7277
7577
  screen.pressInHandlers.clear()
7278
7578
  screen.pressOutHandlers.clear()
@@ -7316,7 +7616,7 @@ class MainActivity : AppCompatActivity() {
7316
7616
  val screen = screens.lastOrNull() ?: return back()
7317
7617
  val modal = screen.modalVisibility.entries.lastOrNull { (view, visible) -> visible && view.visibility == View.VISIBLE }
7318
7618
  if (modal != null) {
7319
- (modal.key as? CraftNativeModalView)?.requestClose()
7619
+ (modal.key as? CraftNativeModalView)?.requestClose(reason = "back")
7320
7620
  return
7321
7621
  }
7322
7622
  back()