craft-native 0.0.175 → 0.0.177

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 (34) hide show
  1. package/dist/android/src/index.d.ts +19 -0
  2. package/dist/android/src/index.js +19 -2
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +51 -0
  5. package/dist/android/templates/CraftCrashReporter.kt.template +544 -0
  6. package/dist/android/templates/CraftNative.kt.template +17 -0
  7. package/dist/android/templates/CraftNativeFlatList.kt.template +20 -9
  8. package/dist/android/templates/CraftNativeMutation.kt.template +4 -1
  9. package/dist/android/templates/CraftNativeServices.kt.template +31 -1
  10. package/dist/android/templates/MainActivityNative.kt.template +986 -304
  11. package/dist/android/templates/build.gradle.kts.app.template +4 -2
  12. package/dist/api/mobile.d.ts +31 -0
  13. package/dist/cli.js +1 -1
  14. package/dist/components/index.d.ts +203 -2
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/src/index.d.ts +19 -0
  20. package/dist/ios/src/index.js +7 -1
  21. package/dist/ios/src/testing.d.ts +5 -0
  22. package/dist/ios/templates/CraftApp.swift +51 -1
  23. package/dist/ios/templates/CraftCrashReporter.swift +1010 -0
  24. package/dist/ios/templates/CraftHybrid.swift +10 -0
  25. package/dist/ios/templates/CraftNativeActions.swift +49 -1
  26. package/dist/ios/templates/CraftNativeFlatList.swift +30 -28
  27. package/dist/ios/templates/CraftNativeMutation.swift +1 -1
  28. package/dist/ios/templates/CraftNativeScreen.swift +559 -86
  29. package/dist/ios/templates/project.yml.template +1 -0
  30. package/dist/ios/templates/testing/DriverTests.swift.template +8 -0
  31. package/dist/macos/templates/CraftNativeMutation.swift +1 -1
  32. package/dist/macos/templates/native_layout.zig +389 -17
  33. package/dist/mobile.js +66 -0
  34. package/package.json +1 -1
@@ -12,7 +12,9 @@ import android.database.ContentObserver
12
12
  import android.content.Intent
13
13
  import android.media.AudioAttributes
14
14
  import android.media.AudioFormat
15
+ import android.media.MediaPlayer
15
16
  import android.media.AudioTrack
17
+ import android.graphics.Bitmap
16
18
  import android.graphics.BitmapFactory
17
19
  import android.graphics.Canvas
18
20
  import android.graphics.Color
@@ -54,6 +56,7 @@ import android.text.Layout
54
56
  import android.view.View
55
57
  import android.view.ViewGroup
56
58
  import android.view.ViewOutlineProvider
59
+ import android.view.ViewParent
57
60
  import android.view.ViewTreeObserver
58
61
  import android.widget.AdapterView
59
62
  import android.widget.ArrayAdapter
@@ -80,6 +83,7 @@ import androidx.core.content.ContextCompat
80
83
  import androidx.core.view.AccessibilityDelegateCompat
81
84
  import androidx.core.view.WindowInsetsCompat
82
85
  import androidx.core.view.ViewCompat
86
+ import androidx.core.view.WindowCompat
83
87
  import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
84
88
  import androidx.javascriptengine.JavaScriptIsolate
85
89
  import androidx.javascriptengine.JavaScriptConsoleCallback
@@ -113,26 +117,63 @@ import kotlin.math.max
113
117
  import kotlin.math.min
114
118
  import kotlin.math.PI
115
119
  import kotlin.math.ceil
120
+ import kotlin.math.abs
121
+ import kotlin.math.exp
116
122
  import kotlin.math.roundToInt
117
123
  import kotlin.math.roundToLong
118
124
  import kotlin.math.sin
125
+ import kotlin.math.pow
119
126
 
120
127
  private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
121
128
 
129
+ private fun craftNativeOptionalString(value: Any?): String? = value
130
+ ?.takeUnless { it == JSONObject.NULL }
131
+ ?.toString()
132
+ ?.takeUnless { it == "null" || it == "undefined" }
133
+ ?.takeIf { it.isNotBlank() }
134
+
135
+ private fun craftNativeEventName(events: JSONObject, vararg names: String): String =
136
+ names.firstNotNullOfOrNull { name -> craftNativeOptionalString(events.opt(name)) }.orEmpty()
137
+
138
+ internal fun craftNativePadding(style: JSONObject?, name: String, fallback: Int, density: Float): Int {
139
+ val value = style?.optDouble(name, Double.NaN) ?: Double.NaN
140
+ val pixels = value * density
141
+ return if (value.isFinite() && value >= 0 && pixels.isFinite()) pixels.toInt() else fallback
142
+ }
143
+
144
+ internal fun craftNativeFiniteFloat(style: JSONObject?, name: String, fallback: Float): Float {
145
+ val value = style?.optDouble(name, Double.NaN) ?: Double.NaN
146
+ val narrowed = value.toFloat()
147
+ return if (value.isFinite() && narrowed.isFinite()) narrowed else fallback
148
+ }
149
+
150
+ internal fun craftNativeFiniteDouble(style: JSONObject?, name: String, fallback: Double): Double {
151
+ val value = style?.optDouble(name, Double.NaN) ?: Double.NaN
152
+ return value.takeIf { it.isFinite() } ?: fallback
153
+ }
154
+
155
+ internal fun craftNativeNonNegativeDouble(style: JSONObject?, name: String, fallback: Double): Double {
156
+ val value = craftNativeFiniteDouble(style, name, Double.NaN)
157
+ return if (value.isFinite() && value >= 0) value else fallback
158
+ }
159
+
122
160
  private data class CraftNativeLength(
123
161
  val points: Int? = null,
124
162
  val fraction: Double? = null,
125
163
  ) {
126
- fun resolve(base: Int?): Int? = points ?: fraction?.let { value -> base?.let { max(0, (it * value).roundToInt()) } }
164
+ fun resolve(base: Int?): Int? = points?.coerceAtLeast(0) ?: fraction?.let { value -> base?.let { max(0, (it * value).roundToInt()) } }
127
165
 
128
166
  companion object {
129
167
  fun from(value: Any?, density: Float): CraftNativeLength? {
130
168
  if (value == null || value == JSONObject.NULL) return null
131
- if (value is Number) return CraftNativeLength(points = (value.toDouble() * density).roundToInt())
169
+ if (value is Number) {
170
+ val points = value.toDouble() * density
171
+ return points.takeIf { it.isFinite() }?.let { CraftNativeLength(points = it.roundToInt()) }
172
+ }
132
173
  val text = value.toString().trim()
133
- if (text.endsWith('%')) return text.dropLast(1).toDoubleOrNull()?.let { CraftNativeLength(fraction = it / 100) }
134
- if (text.endsWith("px")) return text.dropLast(2).toDoubleOrNull()?.let { CraftNativeLength(points = (it * density).roundToInt()) }
135
- return text.toDoubleOrNull()?.let { CraftNativeLength(points = (it * density).roundToInt()) }
174
+ if (text.endsWith('%')) return text.dropLast(1).toDoubleOrNull()?.takeIf { it.isFinite() }?.let { CraftNativeLength(fraction = it / 100) }
175
+ if (text.endsWith("px")) return text.dropLast(2).toDoubleOrNull()?.takeIf { it.isFinite() }?.let { CraftNativeLength(points = (it * density).roundToInt()) }
176
+ return text.toDoubleOrNull()?.takeIf { it.isFinite() }?.let { CraftNativeLength(points = (it * density).roundToInt()) }
136
177
  }
137
178
  }
138
179
  }
@@ -160,11 +201,13 @@ private data class CraftNativeLayoutStyle(
160
201
  fun length(name: String): CraftNativeLength? = CraftNativeLength.from(raw.opt(name), density)
161
202
  fun number(name: String): Int? {
162
203
  if (!raw.has(name) || raw.isNull(name)) return null
163
- return raw.optDouble(name, Double.NaN).takeUnless { it.isNaN() }?.let { (it * density).roundToInt() }
204
+ return raw.optDouble(name, Double.NaN).takeIf { it.isFinite() }?.let { points ->
205
+ (points * density).takeIf { it.isFinite() }?.roundToInt()
206
+ }
164
207
  }
165
208
  fun decimal(name: String): Double? {
166
209
  if (!raw.has(name) || raw.isNull(name)) return null
167
- return raw.optDouble(name, Double.NaN).takeUnless { it.isNaN() }
210
+ return raw.optDouble(name, Double.NaN).takeIf { it.isFinite() }
168
211
  }
169
212
  val flex = decimal("flex")
170
213
  return CraftNativeLayoutStyle(
@@ -277,6 +320,10 @@ private class CraftNativeSvgView(context: Context) : View(context) {
277
320
  val color: String = "#000000",
278
321
  val opacity: Float = 1f,
279
322
  val typeface: Typeface = Typeface.DEFAULT,
323
+ val strokeLinecap: String? = null,
324
+ val strokeLinejoin: String? = null,
325
+ val strokeDasharray: List<Float> = emptyList(),
326
+ val vectorEffect: String? = null,
280
327
  ) {
281
328
  fun merge(node: JSONObject): SvgPaint {
282
329
  val props = node.optJSONObject("props") ?: JSONObject()
@@ -286,17 +333,32 @@ private class CraftNativeSvgView(context: Context) : View(context) {
286
333
  props.has(name) && !props.isNull(name) -> props.optString(name)
287
334
  else -> null
288
335
  }
336
+ fun raw(name: String): Any? = when {
337
+ style.has(name) && !style.isNull(name) -> style.opt(name)
338
+ props.has(name) && !props.isNull(name) -> props.opt(name)
339
+ else -> null
340
+ }
341
+ fun dashes(rawValue: Any?): List<Float>? {
342
+ val values = rawValue as? JSONArray ?: return null
343
+ return (0 until values.length()).mapNotNull {
344
+ values.optDouble(it, Double.NaN).toFloat().takeIf { value -> value.isFinite() && value >= 0 }
345
+ }
346
+ }
289
347
  // Keep the cascade explicit: computed style wins over a raw prop
290
348
  // when both provide the same SVG paint value.
291
349
  val weight = value("fontWeight")
292
- val ownOpacity = value("opacity")?.toFloatOrNull() ?: 1f
350
+ val ownOpacity = value("opacity")?.toFloatOrNull()?.takeIf { it.isFinite() } ?: 1f
293
351
  return copy(
294
352
  fill = value("fill") ?: fill,
295
353
  stroke = value("stroke") ?: stroke,
296
- strokeWidth = value("strokeWidth")?.toFloatOrNull() ?: strokeWidth,
354
+ strokeWidth = value("strokeWidth")?.toFloatOrNull()?.takeIf { it.isFinite() } ?: strokeWidth,
297
355
  color = value("color") ?: color,
298
356
  opacity = ownOpacity * opacity,
299
357
  typeface = if (weight in setOf("bold", "700")) Typeface.DEFAULT_BOLD else typeface,
358
+ strokeLinecap = value("strokeLinecap") ?: strokeLinecap,
359
+ strokeLinejoin = value("strokeLinejoin") ?: strokeLinejoin,
360
+ strokeDasharray = dashes(raw("strokeDasharray")) ?: strokeDasharray,
361
+ vectorEffect = value("vectorEffect") ?: vectorEffect,
300
362
  )
301
363
  }
302
364
  }
@@ -354,7 +416,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
354
416
  val y = values.optDouble(1, Double.NaN).toFloat()
355
417
  val width = values.optDouble(2, Double.NaN).toFloat()
356
418
  val height = values.optDouble(3, Double.NaN).toFloat()
357
- if (!x.isFinite() || !y.isFinite() || !width.isFinite() || !height.isFinite()) return null
419
+ if (!x.isFinite() || !y.isFinite() || !width.isFinite() || !height.isFinite() || width <= 0f || height <= 0f) return null
358
420
  return Quadruple(width, height, x, y)
359
421
  }
360
422
 
@@ -375,6 +437,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
375
437
  private fun drawPath(canvas: Canvas, node: JSONObject, style: SvgPaint) {
376
438
  val props = node.optJSONObject("props") ?: JSONObject()
377
439
  val geometry = props.optJSONArray("geometry") ?: return
440
+ if ((0 until geometry.length()).any { !geometry.optDouble(it, Double.NaN).toFloat().isFinite() }) return
378
441
  val path = Path()
379
442
  var index = 0
380
443
  while (index < geometry.length()) {
@@ -401,34 +464,29 @@ private class CraftNativeSvgView(context: Context) : View(context) {
401
464
  alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
402
465
  this.style = Paint.Style.STROKE
403
466
  strokeWidth = style.strokeWidth
404
- strokeCap = when (props.optString("strokeLinecap")) {
467
+ strokeCap = when (style.strokeLinecap) {
405
468
  "round" -> Paint.Cap.ROUND
406
469
  "square" -> Paint.Cap.SQUARE
407
470
  else -> Paint.Cap.BUTT
408
471
  }
409
- strokeJoin = when (props.optString("strokeLinejoin")) {
472
+ strokeJoin = when (style.strokeLinejoin) {
410
473
  "round" -> Paint.Join.ROUND
411
474
  "bevel" -> Paint.Join.BEVEL
412
475
  else -> Paint.Join.MITER
413
476
  }
414
- props.optJSONArray("strokeDasharray")?.let { values ->
415
- val dashes: List<Float> = (0 until values.length()).mapNotNull {
416
- values.optDouble(it, Double.NaN).takeUnless { value -> value.isNaN() }?.toFloat()
417
- }
418
- if (dashes.isNotEmpty()) {
419
- val dashPattern = dashes.toFloatArray()
420
- val pattern: FloatArray = if (dashPattern.size % 2 == 1) {
421
- FloatArray(dashPattern.size * 2) { index -> dashPattern[index % dashPattern.size] }
422
- } else {
423
- dashPattern
424
- }
425
- pathEffect = DashPathEffect(pattern, 0f)
477
+ if (style.strokeDasharray.isNotEmpty()) {
478
+ val dashPattern = style.strokeDasharray.toFloatArray()
479
+ val pattern: FloatArray = if (dashPattern.size % 2 == 1) {
480
+ FloatArray(dashPattern.size * 2) { index -> dashPattern[index % dashPattern.size] }
481
+ } else {
482
+ dashPattern
426
483
  }
484
+ pathEffect = DashPathEffect(pattern, 0f)
427
485
  }
428
486
  }
429
487
  // `vector-effect="non-scaling-stroke"`: the stroke is in the view's
430
488
  // pixels, so a stretched chart's line keeps its width.
431
- if (props.optString("vectorEffect") == "non-scaling-stroke") {
489
+ if (style.vectorEffect == "non-scaling-stroke") {
432
490
  val line = Path(path).apply { transform(viewTransform) }
433
491
  canvas.save()
434
492
  canvas.concat(inverseTransform)
@@ -449,15 +507,17 @@ private class CraftNativeSvgView(context: Context) : View(context) {
449
507
  }
450
508
  if (text.isEmpty()) return
451
509
  val size = (if (props.has("fontSize")) props.optDouble("fontSize", 12.0) else nodeStyle.optDouble("fontSize", 12.0))
452
- .toFloat()
510
+ .takeIf { it.isFinite() }
511
+ ?.toFloat()
512
+ ?: 12f
453
513
  val paint = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
454
514
  color = resolveColor(style.fill, style.color, Color.BLACK)
455
515
  alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
456
516
  textSize = size
457
517
  typeface = style.typeface
458
518
  }
459
- var x = props.optDouble("x", 0.0).toFloat()
460
- val y = props.optDouble("y", 0.0).toFloat()
519
+ var x = props.optDouble("x", 0.0).takeIf { it.isFinite() }?.toFloat() ?: 0f
520
+ val y = props.optDouble("y", 0.0).takeIf { it.isFinite() }?.toFloat() ?: 0f
461
521
  when (props.optString("textAnchor")) {
462
522
  "middle" -> x -= paint.measureText(text) / 2f
463
523
  "end" -> x -= paint.measureText(text)
@@ -482,6 +542,8 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
482
542
  private val polylines = mutableMapOf<String, Polyline>()
483
543
  private val markerHandlers = mutableMapOf<String, String>()
484
544
  private var onMarkerTap: ((String) -> Unit)? = null
545
+ private var lifecycleStarted = false
546
+ private var destroyed = false
485
547
 
486
548
  init {
487
549
  addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
@@ -490,11 +552,15 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
490
552
  }
491
553
 
492
554
  override fun onMapReady(map: GoogleMap) {
555
+ if (destroyed) return
493
556
  googleMap = map
494
557
  map.setOnMarkerClickListener { marker ->
495
558
  val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
496
559
  onMarkerTap?.invoke(id)
497
- false
560
+ // The native contract dispatches the marker event itself. Consume
561
+ // the click so Google Maps does not also show an unsolicited info
562
+ // window for the marker title.
563
+ true
498
564
  }
499
565
  applyPending()
500
566
  }
@@ -513,7 +579,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
513
579
  map.uiSettings.isScrollGesturesEnabled = pendingProps.optBoolean("scrollEnabled", true)
514
580
  map.uiSettings.isZoomGesturesEnabled = pendingProps.optBoolean("zoomEnabled", true)
515
581
  map.uiSettings.isRotateGesturesEnabled = pendingProps.optBoolean("rotateEnabled", false)
516
- map.mapType = when (pendingProps.optString("mapType").lowercase()) {
582
+ map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) {
517
583
  "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
518
584
  "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
519
585
  "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
@@ -529,20 +595,32 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
529
595
  val type = child.optString("type").lowercase()
530
596
  val props = child.optJSONObject("props") ?: JSONObject()
531
597
  val events = child.optJSONObject("events") ?: JSONObject()
532
- val id = props.opt("id")?.takeUnless { it == JSONObject.NULL }?.toString()
533
- ?: child.optString("id").takeIf { it.isNotBlank() }
598
+ val id = craftNativeOptionalString(props.opt("id"))
599
+ ?: craftNativeOptionalString(child.opt("id"))
534
600
  ?: "overlay-$index"
535
601
  val points = coordinates(props.optJSONArray("geometry"))
536
602
  if (type == "mapmarker" || type == "marker") {
537
603
  points.firstOrNull()?.let { point ->
538
- nextMarkers[id] = point to props.optString("title").takeIf { it.isNotBlank() }
539
- events.optString("onPress").ifBlank { events.optString("onMarkerPress") }
540
- .takeIf { it.isNotBlank() }?.let { nextHandlers[id] = it }
604
+ nextMarkers[id] = point to craftNativeOptionalString(props.opt("title"))
605
+ listOf(events.opt("onPress"), events.opt("onMarkerPress"))
606
+ .firstNotNullOfOrNull(::craftNativeOptionalString)
607
+ ?.let { nextHandlers[id] = it }
541
608
  }
542
609
  } else if (type == "mappolyline" || type == "polyline") {
543
610
  if (points.size > 1) {
544
- val width = props.optDouble("strokeWidth", 3.0).toFloat().takeIf { it.isFinite() && it > 0 } ?: 3f
545
- nextPolylines[id] = Triple(points, color(props.optString("strokeColor")), width)
611
+ // Keep malformed authored widths inert, matching the iOS
612
+ // host. An omitted or null width still gets the shared
613
+ // default; an attacker-controlled NaN, infinity, zero,
614
+ // or negative value must not become a valid overlay.
615
+ val rawWidth = props.opt("strokeWidth")
616
+ val authoredWidth = when (rawWidth) {
617
+ null, JSONObject.NULL -> 3.0
618
+ is Number -> rawWidth.toDouble()
619
+ else -> 3.0
620
+ }
621
+ val width = authoredWidth.toFloat()
622
+ if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
623
+ nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
546
624
  }
547
625
  }
548
626
  }
@@ -558,10 +636,18 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
558
636
  }
559
637
  markerHandlers.clear()
560
638
  markerHandlers.putAll(nextHandlers)
561
- polylines.values.forEach { it.remove() }
562
- polylines.clear()
639
+ polylines.keys.filter { nextPolylines[it] == null }.forEach { id ->
640
+ polylines.remove(id)?.remove()
641
+ }
563
642
  for ((id, value) in nextPolylines) {
564
- polylines[id] = map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))
643
+ val polyline = polylines[id]
644
+ if (polyline != null) {
645
+ polyline.points = value.first
646
+ polyline.color = value.second
647
+ polyline.width = value.third
648
+ } else {
649
+ map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it }
650
+ }
565
651
  }
566
652
  if (pendingProps.optBoolean("fitToBounds", false)) fitToBounds()
567
653
  region(pendingProps.optJSONObject("region") ?: pendingProps.optJSONObject("initialRegion"))?.let { bounds ->
@@ -585,16 +671,43 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
585
671
 
586
672
  override fun onAttachedToWindow() {
587
673
  super.onAttachedToWindow()
674
+ if (destroyed) return
588
675
  mapView.onStart()
589
676
  mapView.onResume()
677
+ lifecycleStarted = true
590
678
  }
591
679
 
592
680
  override fun onDetachedFromWindow() {
593
- mapView.onPause()
594
- mapView.onStop()
681
+ if (lifecycleStarted) {
682
+ mapView.onPause()
683
+ mapView.onStop()
684
+ lifecycleStarted = false
685
+ }
595
686
  super.onDetachedFromWindow()
596
687
  }
597
688
 
689
+ fun destroy() {
690
+ if (destroyed) return
691
+ destroyed = true
692
+ if (lifecycleStarted) {
693
+ mapView.onPause()
694
+ mapView.onStop()
695
+ lifecycleStarted = false
696
+ }
697
+ onMarkerTap = null
698
+ markerHandlers.clear()
699
+ markers.values.forEach { it.remove() }
700
+ markers.clear()
701
+ polylines.values.forEach { it.remove() }
702
+ polylines.clear()
703
+ mapView.onDestroy()
704
+ googleMap = null
705
+ }
706
+
707
+ fun lowMemory() {
708
+ if (!destroyed) mapView.onLowMemory()
709
+ }
710
+
598
711
  private fun hasLocationPermission(): Boolean =
599
712
  ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
600
713
  ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
@@ -604,7 +717,10 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
604
717
  return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
605
718
  val latitude = geometry.optDouble(index, Double.NaN)
606
719
  val longitude = geometry.optDouble(index + 1, Double.NaN)
607
- if (latitude.isFinite() && longitude.isFinite()) LatLng(latitude, longitude) else null
720
+ if (latitude.isFinite() && longitude.isFinite() &&
721
+ latitude >= -90.0 && latitude <= 90.0 &&
722
+ longitude >= -180.0 && longitude <= 180.0
723
+ ) LatLng(latitude, longitude) else null
608
724
  }
609
725
  }
610
726
 
@@ -615,7 +731,12 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
615
731
  val longitude = center.optDouble("longitude", Double.NaN)
616
732
  val latitudeDelta = span.optDouble("latitudeDelta", span.optDouble("latitude_delta", Double.NaN))
617
733
  val longitudeDelta = span.optDouble("longitudeDelta", span.optDouble("longitude_delta", Double.NaN))
618
- if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite()) return null
734
+ if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() ||
735
+ latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 ||
736
+ latitudeDelta <= 0.0 || latitudeDelta > 180.0 ||
737
+ longitudeDelta <= 0.0 || longitudeDelta > 360.0 ||
738
+ latitude - latitudeDelta / 2.0 < -90.0 || latitude + latitudeDelta / 2.0 > 90.0
739
+ ) return null
619
740
  return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
620
741
  }
621
742
 
@@ -661,6 +782,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
661
782
  (0 until raw.length()).mapNotNull { raw.optString(it).takeIf { edge -> edge in setOf("top", "right", "bottom", "left") } }.toSet()
662
783
  }
663
784
  applySafeAreaPadding()
785
+ invalidateMeasurements()
664
786
  }
665
787
 
666
788
  fun setWindowSafeInsets(left: Int, top: Int, right: Int, bottom: Int) {
@@ -672,6 +794,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
672
794
  if ("bottom" in safeAreaEdges) insets[3] else 0,
673
795
  )
674
796
  applySafeAreaPadding()
797
+ invalidateMeasurements()
675
798
  }
676
799
 
677
800
  private fun applySafeAreaPadding() {
@@ -720,6 +843,31 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
720
843
  private fun probedWidth(size: Long): Int = (size shr 32).toInt()
721
844
  private fun probedHeight(size: Long): Int = size.toInt()
722
845
 
846
+ /**
847
+ * Reactive style and inset mutations can arrive during one frame. The
848
+ * memo is intentionally frame-scoped for performance, but a mutation
849
+ * must invalidate it immediately or the next layout can reuse a child's
850
+ * old intrinsic width/height.
851
+ */
852
+ private fun invalidateMeasurements() {
853
+ memos.clear()
854
+ lastProbe.clear()
855
+ forceLayout()
856
+ requestLayout()
857
+ }
858
+
859
+ private fun invalidateMeasurementAncestors(start: ViewParent?) {
860
+ var ancestor = start
861
+ while (ancestor != null) {
862
+ when (ancestor) {
863
+ is CraftNativeFlexLayout -> ancestor.invalidateMeasurements()
864
+ is CraftNativeKeyboardAvoidingView -> ancestor.content.invalidateMeasurements()
865
+ is CraftNativeScrollView -> ancestor.invalidateContentLayout()
866
+ }
867
+ ancestor = (ancestor as? View)?.parent
868
+ }
869
+ }
870
+
723
871
  /**
724
872
  * Lays a child out at its final frame, measuring it again at exactly
725
873
  * that size first when the measure pass left it another size (a
@@ -757,34 +905,50 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
757
905
  alignItems = style.optString("alignItems", "stretch")
758
906
  alignContent = style.optString("alignContent", "flex-start")
759
907
  justifyContent = style.optString("justifyContent", "flex-start")
760
- val gap = style.optDouble("gap", 0.0) * density
761
- rowGap = style.optDouble("rowGap", gap / density.toDouble()).toFloat() * density
762
- columnGap = style.optDouble("columnGap", gap / density.toDouble()).toFloat() * density
908
+ fun spacing(name: String, fallback: Float): Float {
909
+ val value = style.optDouble(name, Double.NaN)
910
+ val pixels = value * density
911
+ val narrowed = pixels.toFloat()
912
+ return if (value.isFinite() && value >= 0 && pixels.isFinite() && narrowed.isFinite()) narrowed else fallback
913
+ }
914
+ val gap = spacing("gap", 0f)
915
+ rowGap = spacing("rowGap", gap)
916
+ columnGap = spacing("columnGap", gap)
763
917
  grid = style.optString("display") == "grid"
764
918
  gridColumns = craftNativeGridColumnCount(
765
919
  if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
766
920
  )
767
921
  gridAutoRows = CraftNativeLength.from(style.opt("gridAutoRows"), density)
768
- val padding = (style.optDouble("padding", 0.0) * density).toInt()
769
- val horizontal = (style.optDouble("paddingHorizontal", padding.toDouble() / density.toDouble()) * density).toInt()
770
- val vertical = (style.optDouble("paddingVertical", padding.toDouble() / density.toDouble()) * density).toInt()
922
+ val all = craftNativePadding(style, "padding", 0, density)
923
+ val horizontal = craftNativePadding(style, "paddingHorizontal", all, density)
924
+ val vertical = craftNativePadding(style, "paddingVertical", all, density)
771
925
  setPadding(
772
- (style.optDouble("paddingLeft", horizontal.toDouble() / density.toDouble()) * density).toInt(),
773
- (style.optDouble("paddingTop", vertical.toDouble() / density.toDouble()) * density).toInt(),
774
- (style.optDouble("paddingRight", horizontal.toDouble() / density.toDouble()) * density).toInt(),
775
- (style.optDouble("paddingBottom", vertical.toDouble() / density.toDouble()) * density).toInt(),
926
+ craftNativePadding(style, "paddingLeft", horizontal, density),
927
+ craftNativePadding(style, "paddingTop", vertical, density),
928
+ craftNativePadding(style, "paddingRight", horizontal, density),
929
+ craftNativePadding(style, "paddingBottom", vertical, density),
776
930
  )
777
- requestLayout()
931
+ invalidateMeasurements()
778
932
  }
779
933
 
780
934
  fun setChildStyle(view: View, style: JSONObject, density: Float) {
781
935
  styles[view] = CraftNativeLayoutStyle.from(style, density)
782
- requestLayout()
936
+ invalidateMeasurements()
937
+ invalidateMeasurementAncestors(parent)
783
938
  }
784
939
 
785
940
  override fun onViewRemoved(child: View) {
941
+ val parentBeforeRemoval = parent
786
942
  styles.remove(child)
787
943
  super.onViewRemoved(child)
944
+ invalidateMeasurements()
945
+ invalidateMeasurementAncestors(parentBeforeRemoval)
946
+ }
947
+
948
+ override fun onViewAdded(child: View) {
949
+ super.onViewAdded(child)
950
+ invalidateMeasurements()
951
+ invalidateMeasurementAncestors(parent)
788
952
  }
789
953
 
790
954
  private data class Entry(val view: View, val style: CraftNativeLayoutStyle, val width: Int, val height: Int)
@@ -1038,6 +1202,116 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1038
1202
  else -> 0.0
1039
1203
  }
1040
1204
 
1205
+ private fun sharedAlignItemsCode(value: String): Int = when (value) {
1206
+ "center" -> 1
1207
+ "flex-end" -> 2
1208
+ "stretch" -> 3
1209
+ "baseline" -> 4
1210
+ else -> 0
1211
+ }
1212
+
1213
+ private fun sharedAlignSelfCode(value: String?): Int = when (value) {
1214
+ "flex-start" -> 1
1215
+ "center" -> 2
1216
+ "flex-end" -> 3
1217
+ "stretch" -> 4
1218
+ "baseline" -> 5
1219
+ else -> 0
1220
+ }
1221
+
1222
+ private fun sharedLength(value: CraftNativeLength?, base: Int?): Float = value?.resolve(base)?.toFloat() ?: Float.NaN
1223
+
1224
+ /**
1225
+ * Use the host-neutral tree ABI when platform measurements are complete:
1226
+ * definite, non-grid direct-child containers, including wrapped flows with
1227
+ * independent row and column gaps. Reverse flows, absolute children,
1228
+ * missing runtimes, and malformed results deliberately fall through to
1229
+ * the established Kotlin layout below.
1230
+ */
1231
+ private fun sharedDirectFrames(entries: List<Entry>, width: Int, height: Int): FloatArray? {
1232
+ if (entries.isEmpty() || width < 0 || height < 0 || grid || reverse || wrapReverse) return null
1233
+ val count = entries.size + 1
1234
+ val fields = 32
1235
+ val nodeData = FloatArray(count * fields) { Float.NaN }
1236
+ val treeMeta = FloatArray(count * 2)
1237
+ fun enumBits(value: Int): Float = Float.fromBits(value)
1238
+ fun put(base: Int, index: Int, value: Float) { nodeData[base + index] = value }
1239
+ fun putEnum(base: Int, index: Int, value: Int) { put(base, index, enumBits(value)) }
1240
+ val contentWidth = max(0, width - paddingLeft - paddingRight)
1241
+ val contentHeight = max(0, height - paddingTop - paddingBottom)
1242
+ val mainSize = if (orientation == HORIZONTAL) contentWidth else contentHeight
1243
+ fun writeStyle(
1244
+ base: Int,
1245
+ style: CraftNativeLayoutStyle,
1246
+ intrinsicWidth: Float,
1247
+ intrinsicHeight: Float,
1248
+ baseline: Float,
1249
+ margins: IntArray,
1250
+ direction: Int,
1251
+ padding: IntArray,
1252
+ gap: Float,
1253
+ rowGapValue: Float,
1254
+ columnGapValue: Float,
1255
+ root: Boolean,
1256
+ view: View?,
1257
+ ) {
1258
+ putEnum(base, 0, direction)
1259
+ put(base, 1, if (root) width.toFloat() else sharedLength(style.width, contentWidth))
1260
+ put(base, 2, if (root) height.toFloat() else sharedLength(style.height, contentHeight))
1261
+ put(base, 3, if (root) Float.NaN else intrinsicWidth)
1262
+ put(base, 4, if (root) Float.NaN else intrinsicHeight)
1263
+ put(base, 5, if (root) Float.NaN else baseline)
1264
+ put(base, 6, style.aspectRatio?.toFloat() ?: Float.NaN)
1265
+ put(base, 7, sharedLength(style.flexBasis, mainSize))
1266
+ put(base, 8, if (root) 0f else style.flexGrow.toFloat().coerceAtLeast(0f))
1267
+ put(base, 9, if (root) 0f else (style.flexShrink ?: view?.let { defaultFlexShrink(it) } ?: 0.0).toFloat().coerceAtLeast(0f))
1268
+ put(base, 10, sharedLength(style.minWidth, contentWidth))
1269
+ put(base, 11, sharedLength(style.maxWidth, contentWidth))
1270
+ put(base, 12, sharedLength(style.minHeight, contentHeight))
1271
+ put(base, 13, sharedLength(style.maxHeight, contentHeight))
1272
+ put(base, 14, if (root) gap else 0f)
1273
+ putEnum(base, 15, if (root) justifyCode() else 0)
1274
+ putEnum(base, 16, if (root) sharedAlignItemsCode(alignItems) else 3)
1275
+ putEnum(base, 17, if (root) 0 else sharedAlignSelfCode(style.alignSelf))
1276
+ putEnum(base, 18, if (root && wrap) 1 else 0)
1277
+ putEnum(base, 19, if (root) alignContentCode() else 6)
1278
+ put(base, 20, margins[2].toFloat())
1279
+ put(base, 21, margins[1].toFloat())
1280
+ put(base, 22, margins[3].toFloat())
1281
+ put(base, 23, margins[0].toFloat())
1282
+ put(base, 24, padding[2].toFloat())
1283
+ put(base, 25, padding[1].toFloat())
1284
+ put(base, 26, padding[3].toFloat())
1285
+ put(base, 27, padding[0].toFloat())
1286
+ putEnum(base, 28, 0)
1287
+ putEnum(base, 29, 1)
1288
+ put(base, 30, if (root) rowGapValue else 0f)
1289
+ put(base, 31, if (root) columnGapValue else 0f)
1290
+ }
1291
+
1292
+ treeMeta[0] = 1f
1293
+ treeMeta[1] = entries.size.toFloat()
1294
+ val rootMargins = intArrayOf(0, 0, 0, 0)
1295
+ val rootPadding = intArrayOf(paddingLeft, paddingRight, paddingTop, paddingBottom)
1296
+ val mainGap = if (orientation == HORIZONTAL) columnGap else rowGap
1297
+ writeStyle(
1298
+ 0, CraftNativeLayoutStyle(), 0f, 0f, 0f, rootMargins,
1299
+ if (orientation == HORIZONTAL) 0 else 1, rootPadding, mainGap, rowGap, columnGap, true, null,
1300
+ )
1301
+ entries.forEachIndexed { index, entry ->
1302
+ val base = (index + 1) * fields
1303
+ val lp = entry.view.layoutParams as? MarginLayoutParams
1304
+ val margins = intArrayOf(lp?.leftMargin ?: 0, lp?.rightMargin ?: 0, lp?.topMargin ?: 0, lp?.bottomMargin ?: 0)
1305
+ treeMeta[(index + 1) * 2] = 0f
1306
+ treeMeta[(index + 1) * 2 + 1] = 0f
1307
+ writeStyle(
1308
+ base, entry.style, entry.width.toFloat(), entry.height.toFloat(), baselineOffset(entry.view, entry.height).toFloat(),
1309
+ margins, 0, intArrayOf(0, 0, 0, 0), 0f, 0f, 0f, false, entry.view,
1310
+ )
1311
+ }
1312
+ return CraftNative.layoutTree(width.toFloat(), height.toFloat(), nodeData, treeMeta)
1313
+ }
1314
+
1041
1315
  private fun justifyCode(): Int = when (justifyContent) {
1042
1316
  "center" -> 1
1043
1317
  "flex-end" -> 2
@@ -1165,11 +1439,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1165
1439
  contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap, rowGap,
1166
1440
  flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
1167
1441
  flow.map { it.style.width?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
1168
- flow.map { it.style.height?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
1442
+ flow.map { it.style.height?.resolve(contentHeight)?.toFloat() ?: Float.NaN }.toFloatArray(),
1169
1443
  flow.map { it.style.minWidth?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
1170
1444
  flow.map { it.style.maxWidth?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
1171
- flow.map { it.style.minHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
1172
- flow.map { it.style.maxHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
1445
+ flow.map { it.style.minHeight?.resolve(contentHeight)?.toFloat() ?: Float.NaN }.toFloatArray(),
1446
+ flow.map { it.style.maxHeight?.resolve(contentHeight)?.toFloat() ?: Float.NaN }.toFloatArray(),
1173
1447
  flow.flatMap {
1174
1448
  val lp = it.view.layoutParams as? MarginLayoutParams
1175
1449
  listOf((lp?.leftMargin ?: 0).toFloat(), (lp?.rightMargin ?: 0).toFloat(), (lp?.topMargin ?: 0).toFloat(), (lp?.bottomMargin ?: 0).toFloat())
@@ -1255,6 +1529,22 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1255
1529
  val contentWidth = width - paddingLeft - paddingRight
1256
1530
  val contentHeight = height - paddingTop - paddingBottom
1257
1531
  val (lines, absolute) = entries(width, height)
1532
+ if (absolute.isEmpty() && lines.isNotEmpty() && (lines.size == 1 || wrap)) {
1533
+ val sharedEntries = if (wrap) lines.flatMap { it.entries } else lines[0].entries
1534
+ val sharedFrames = sharedDirectFrames(sharedEntries, width, height)
1535
+ if (sharedFrames != null) {
1536
+ for ((index, entry) in sharedEntries.withIndex()) {
1537
+ val offset = (index + 1) * 4
1538
+ val x = sharedFrames[offset].roundToInt()
1539
+ val y = sharedFrames[offset + 1].roundToInt()
1540
+ val childWidth = sharedFrames[offset + 2].roundToInt()
1541
+ val childHeight = sharedFrames[offset + 3].roundToInt()
1542
+ place(entry.view, x, y, x + childWidth, y + childHeight)
1543
+ }
1544
+ onLayoutChanged?.invoke()
1545
+ return
1546
+ }
1547
+ }
1258
1548
  val gap = if (orientation == HORIZONTAL) columnGap else rowGap
1259
1549
  val crossGap = if (orientation == HORIZONTAL) rowGap else columnGap
1260
1550
  val availableMain = if (orientation == HORIZONTAL) contentWidth else contentHeight
@@ -1449,7 +1739,9 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
1449
1739
 
1450
1740
  fun configure(props: JSONObject, style: JSONObject, density: Float) {
1451
1741
  behavior = props.optString("behavior", "padding").takeIf { it in setOf("height", "position", "padding") } ?: "padding"
1452
- keyboardVerticalOffset = (props.optDouble("keyboardVerticalOffset", 0.0) * density).roundToInt()
1742
+ val authoredOffset = craftNativeFiniteDouble(props, "keyboardVerticalOffset", 0.0)
1743
+ val pixelOffset = (authoredOffset * density.toDouble()).takeIf { it.isFinite() } ?: 0.0
1744
+ keyboardVerticalOffset = pixelOffset.roundToInt()
1453
1745
  content.configure(style, density)
1454
1746
  basePadding = intArrayOf(content.paddingLeft, content.paddingTop, content.paddingRight, content.paddingBottom)
1455
1747
  applyKeyboardInset()
@@ -1551,10 +1843,10 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1551
1843
  }
1552
1844
 
1553
1845
  fun setRefreshAppearance(props: JSONObject, density: Float) {
1554
- val tint = props.optString("tintColor").takeIf { it.isNotBlank() }
1846
+ val tint = craftNativeOptionalString(props.opt("tintColor"))
1555
1847
  if (tint != null) setColorSchemeColors(craftNativeColor(tint))
1556
1848
  else setColorSchemeColors(Color.WHITE)
1557
- val background = props.optString("progressBackgroundColor").takeIf { it.isNotBlank() }
1849
+ val background = craftNativeOptionalString(props.opt("progressBackgroundColor"))
1558
1850
  if (background != null) setProgressBackgroundColorSchemeColor(craftNativeColor(background))
1559
1851
  else setProgressBackgroundColorSchemeColor(Color.TRANSPARENT)
1560
1852
  val offset = props.optDouble("progressViewOffset", Double.NaN)
@@ -1915,6 +2207,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1915
2207
  private var pickerMode: PickerMode? = null
1916
2208
  private var datePicker: DatePicker? = null
1917
2209
  private var timePicker: TimePicker? = null
2210
+ private var defaultDateBounds: Pair<Long, Long>? = null
1918
2211
  private var minuteInterval = 1
1919
2212
  private var countdownSecond = 0
1920
2213
  private var suppressChanges = true
@@ -1942,6 +2235,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1942
2235
  }
1943
2236
  if (requestedMode == PickerMode.DATE || requestedMode == PickerMode.DATE_TIME) {
1944
2237
  datePicker = DatePicker(context).also { picker ->
2238
+ defaultDateBounds = picker.minDate to picker.maxDate
1945
2239
  val today = Calendar.getInstance()
1946
2240
  picker.init(today.get(Calendar.YEAR), today.get(Calendar.MONTH), today.get(Calendar.DAY_OF_MONTH)) { _, _, _, _ -> changed() }
1947
2241
  content.addView(picker, LinearLayout.LayoutParams(
@@ -1965,8 +2259,9 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1965
2259
 
1966
2260
  suppressChanges = true
1967
2261
  minuteInterval = props.optInt("minuteInterval", 1).takeIf { it in 1..30 && 60 % it == 0 } ?: 1
1968
- pickerDate(props.opt("minimumDate"))?.let { datePicker?.minDate = it.timeInMillis }
1969
- pickerDate(props.opt("maximumDate"))?.let { datePicker?.maxDate = it.timeInMillis }
2262
+ val defaults = defaultDateBounds
2263
+ datePicker?.minDate = pickerDate(props.opt("minimumDate"))?.timeInMillis ?: defaults?.first ?: datePicker?.minDate ?: 0L
2264
+ datePicker?.maxDate = pickerDate(props.opt("maximumDate"))?.timeInMillis ?: defaults?.second ?: datePicker?.maxDate ?: Long.MAX_VALUE
1970
2265
  if (requestedMode == PickerMode.COUNTDOWN) {
1971
2266
  countdownSecond = 0
1972
2267
  countdownSeconds(props.opt("value"))?.let { applyCountdown(it) }
@@ -2051,7 +2346,11 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2051
2346
  }
2052
2347
 
2053
2348
  private fun pickerDate(value: Any?): Calendar? {
2054
- if (value is Number && value !is Boolean) return Calendar.getInstance().apply { timeInMillis = value.toLong() }
2349
+ if (value is Number && value !is Boolean) {
2350
+ val milliseconds = value.toDouble()
2351
+ if (!milliseconds.isFinite()) return null
2352
+ return runCatching { Calendar.getInstance().apply { timeInMillis = milliseconds.toLong() } }.getOrNull()
2353
+ }
2055
2354
  val text = value?.toString()?.trim().orEmpty()
2056
2355
  if (text.isBlank() || text == JSONObject.NULL.toString()) return null
2057
2356
  val formats = listOf(
@@ -2069,7 +2368,11 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2069
2368
  }
2070
2369
 
2071
2370
  private fun countdownSeconds(value: Any?): Int? {
2072
- if (value is Number && value !is Boolean) return value.toDouble().coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
2371
+ if (value is Number && value !is Boolean) {
2372
+ val seconds = value.toDouble()
2373
+ if (!seconds.isFinite()) return null
2374
+ return seconds.coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
2375
+ }
2073
2376
  val text = value?.toString()?.trim().orEmpty()
2074
2377
  if (text.isBlank() || text == JSONObject.NULL.toString()) return null
2075
2378
  text.toDoubleOrNull()?.takeIf { it.isFinite() }?.let { return it.coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt() }
@@ -2078,7 +2381,8 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2078
2381
  val hours = parts[0].toDoubleOrNull() ?: return null
2079
2382
  val minutes = parts[1].toDoubleOrNull() ?: return null
2080
2383
  val seconds = if (parts.size == 3) parts[2].toDoubleOrNull() ?: return null else 0.0
2081
- if (hours < 0 || minutes !in 0.0..<60.0 || seconds !in 0.0..<60.0) return null
2384
+ if (!hours.isFinite() || !minutes.isFinite() || !seconds.isFinite() ||
2385
+ hours < 0 || minutes !in 0.0..<60.0 || seconds !in 0.0..<60.0) return null
2082
2386
  return (hours * 3600 + minutes * 60 + seconds).coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
2083
2387
  }
2084
2388
  }
@@ -2088,26 +2392,33 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2088
2392
  private var shouldLoop = false
2089
2393
  private var shouldPlay = false
2090
2394
  private var muted = false
2395
+ private var resizeMode = "contain"
2396
+ private var preparedPlayer: MediaPlayer? = null
2091
2397
 
2092
- fun configure(source: Any?, autoplay: Boolean, paused: Boolean, loop: Boolean, muted: Boolean, controls: Boolean) {
2398
+ fun configure(source: Any?, autoplay: Boolean, paused: Boolean, loop: Boolean, muted: Boolean, controls: Boolean, resizeMode: String?) {
2093
2399
  val uri = when (source) {
2094
2400
  is String -> source
2095
- is JSONObject -> source.optString("uri")
2401
+ is JSONObject -> craftNativeOptionalString(source.opt("uri")).orEmpty()
2096
2402
  else -> ""
2097
2403
  }
2098
2404
  shouldLoop = loop
2099
2405
  shouldPlay = autoplay && !paused
2100
2406
  this.muted = muted
2407
+ this.resizeMode = resizeMode?.lowercase()?.takeIf { it in setOf("cover", "contain", "stretch", "center") } ?: "contain"
2408
+ applyScalingMode()
2101
2409
  setMediaController(if (controls) MediaController(context).also { it.setAnchorView(this) } else null)
2102
2410
  if (uri != sourceUri) {
2103
2411
  sourceUri = uri
2104
2412
  stopPlayback()
2413
+ preparedPlayer = null
2105
2414
  if (uri.isBlank()) return
2106
2415
  // A copy kept on the phone (craft.media.keep) plays in place of
2107
2416
  // the address: offline in a gym with no signal.
2108
2417
  val kept = CraftNativeMedia.local(context, uri)
2109
2418
  setVideoURI(if (kept != null) Uri.fromFile(kept) else Uri.parse(uri))
2110
2419
  setOnPreparedListener { mediaPlayer ->
2420
+ preparedPlayer = mediaPlayer
2421
+ applyScalingMode()
2111
2422
  mediaPlayer.isLooping = shouldLoop
2112
2423
  val volume = if (this.muted) 0f else 1f
2113
2424
  mediaPlayer.setVolume(volume, volume)
@@ -2125,12 +2436,27 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2125
2436
  fun stop() {
2126
2437
  stopPlayback()
2127
2438
  sourceUri = ""
2439
+ preparedPlayer = null
2440
+ }
2441
+
2442
+ private fun applyScalingMode() {
2443
+ preparedPlayer?.setVideoScalingMode(
2444
+ if (resizeMode == "cover") MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
2445
+ else MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT,
2446
+ )
2128
2447
  }
2129
2448
  }
2130
2449
 
2131
- /** The oscillator/gain subset of Web Audio used by native STX screens. */
2450
+ /**
2451
+ * The native-screen Web Audio graph. Android renders the same useful graph
2452
+ * shape as iOS: oscillators and buffer sources can pass through gain, filter,
2453
+ * delay, compressor, and convolver nodes before reaching the destination.
2454
+ * Effects deliberately remain conservative in this first Android renderer;
2455
+ * the graph and its timing are native, while the existing WebView remains the
2456
+ * fallback for unsupported media decoding.
2457
+ */
2132
2458
  private class CraftNativeAudio {
2133
- private data class ParameterEvent(val time: Double, val value: Double, val ramp: Boolean)
2459
+ private data class ParameterEvent(val time: Double, val value: Double, val mode: String)
2134
2460
 
2135
2461
  private class Parameter(initial: Double) {
2136
2462
  private var current = initial
@@ -2141,8 +2467,8 @@ private class CraftNativeAudio {
2141
2467
  events.clear()
2142
2468
  }
2143
2469
 
2144
- @Synchronized fun schedule(value: Double, time: Double, ramp: Boolean) {
2145
- events += ParameterEvent(time.coerceAtLeast(0.0), value, ramp)
2470
+ @Synchronized fun schedule(value: Double, time: Double, mode: String) {
2471
+ events += ParameterEvent(time.coerceAtLeast(0.0), value, mode)
2146
2472
  events.sortBy { it.time }
2147
2473
  }
2148
2474
 
@@ -2151,9 +2477,20 @@ private class CraftNativeAudio {
2151
2477
  var previousValue = current
2152
2478
  for (event in events) {
2153
2479
  if (time < event.time) {
2154
- if (!event.ramp || event.time <= previousTime) return previousValue
2480
+ if (event.time <= previousTime) return previousValue
2155
2481
  val fraction = (time - previousTime) / (event.time - previousTime)
2156
- return previousValue + (event.value - previousValue) * fraction
2482
+ return when (event.mode) {
2483
+ "exponential" -> {
2484
+ val start = previousValue.takeIf { it > 0 } ?: 0.000001
2485
+ val end = event.value.takeIf { it > 0 } ?: 0.000001
2486
+ start * (end / start).pow(fraction)
2487
+ }
2488
+ "target" -> {
2489
+ previousValue + (event.value - previousValue) * (1 - exp(-fraction * 5))
2490
+ }
2491
+ "linear" -> previousValue + (event.value - previousValue) * fraction
2492
+ else -> previousValue
2493
+ }
2157
2494
  }
2158
2495
  previousTime = event.time
2159
2496
  previousValue = event.value
@@ -2162,73 +2499,182 @@ private class CraftNativeAudio {
2162
2499
  }
2163
2500
  }
2164
2501
 
2165
- private class OscillatorState {
2502
+ private class NodeState(
2503
+ val contextId: String,
2504
+ val id: String,
2505
+ val kind: String,
2506
+ ) {
2166
2507
  val frequency = Parameter(440.0)
2167
- @Volatile var gain = Parameter(1.0)
2168
- @Volatile var phase = 0.0
2169
- @Volatile var active = false
2170
- var contextStart = 0.0
2171
- }
2508
+ val detune = Parameter(0.0)
2509
+ val gain = Parameter(1.0)
2510
+ val playbackRate = Parameter(1.0)
2511
+ var waveform = 0
2512
+ var bufferId: String? = null
2513
+ var bufferLoop = false
2514
+ var loopStart = 0.0
2515
+ var loopEnd = 0.0
2516
+ var startOffset = 0.0
2517
+ var startAt = Double.POSITIVE_INFINITY
2518
+ var stopAt = Double.POSITIVE_INFINITY
2519
+ }
2520
+
2521
+ private data class BufferSpec(
2522
+ val length: Int,
2523
+ val sampleRate: Double,
2524
+ val channels: List<FloatArray> = emptyList(),
2525
+ )
2172
2526
 
2173
- private val handler = Handler(Looper.getMainLooper())
2174
- private val oscillators = mutableMapOf<String, OscillatorState>()
2175
- private val gains = mutableMapOf<String, Parameter>()
2527
+ private val nodes = mutableMapOf<String, NodeState>()
2528
+ private val inputs = mutableMapOf<String, MutableSet<String>>()
2529
+ private val buffers = mutableMapOf<String, BufferSpec>()
2176
2530
  private val contextStarts = mutableMapOf<String, Double>()
2531
+ private val suspended = mutableSetOf<String>()
2177
2532
  private var track: AudioTrack? = null
2178
2533
  @Volatile private var running = false
2179
2534
  private var renderThread: Thread? = null
2180
2535
 
2536
+ private fun key(contextId: String, nodeId: String): String = "$contextId:$nodeId"
2537
+
2538
+ private fun node(contextId: String, nodeId: String): NodeState? = nodes[key(contextId, nodeId)]
2539
+
2181
2540
  @Synchronized fun handle(payload: JSONObject) {
2541
+ val contextId = payload.optString("contextId")
2182
2542
  when (payload.optString("action")) {
2183
2543
  "createContext" -> {
2184
- payload.optString("contextId").takeIf { it.isNotBlank() }?.let {
2544
+ contextId.takeIf { it.isNotBlank() }?.let {
2185
2545
  contextStarts[it] = System.nanoTime() / 1_000_000_000.0
2546
+ suspended.remove(it)
2186
2547
  }
2187
2548
  }
2188
- "createOscillator" -> {
2549
+ "createOscillator", "createGain", "createBufferSource", "createBiquadFilter",
2550
+ "createDelay", "createDynamicsCompressor", "createConvolver" -> {
2189
2551
  val id = payload.optString("nodeId")
2190
- if (id.isNotBlank()) oscillators.putIfAbsent(id, OscillatorState()).also {
2191
- oscillators[id]?.contextStart = contextStarts[payload.optString("contextId")]
2192
- ?: (System.nanoTime() / 1_000_000_000.0)
2193
- oscillators[id]?.frequency?.set(payload.optDouble("frequency", 440.0).coerceAtLeast(0.0))
2552
+ if (contextId.isBlank() || id.isBlank()) return
2553
+ val state = NodeState(contextId, id, payload.optString("action").removePrefix("create"))
2554
+ state.frequency.set(payload.optDouble("frequency", 440.0).coerceAtLeast(0.0))
2555
+ state.gain.set(payload.optDouble("gain", 1.0).coerceAtLeast(0.0))
2556
+ nodes[key(contextId, id)] = state
2557
+ }
2558
+ "createBuffer" -> {
2559
+ val id = payload.optString("bufferId")
2560
+ if (id.isNotBlank()) {
2561
+ val length = payload.optInt("length", 1).coerceAtLeast(1)
2562
+ val channelCount = payload.optInt("channels", 1).coerceIn(1, 2)
2563
+ buffers[id] = BufferSpec(
2564
+ length,
2565
+ payload.optDouble("sampleRate", 44_100.0).coerceAtLeast(3_000.0),
2566
+ List(channelCount) { FloatArray(length) },
2567
+ )
2194
2568
  }
2195
2569
  }
2196
- "createGain" -> {
2197
- val id = payload.optString("nodeId")
2198
- if (id.isNotBlank()) gains[id] = Parameter(payload.optDouble("gain", 1.0).coerceAtLeast(0.0))
2570
+ "bufferData" -> {
2571
+ val id = payload.optString("bufferId")
2572
+ val existing = buffers[id] ?: return
2573
+ val rawChannels = payload.optJSONArray("channels") ?: return
2574
+ val channels = (0 until min(existing.channels.size, rawChannels.length())).map { channelIndex ->
2575
+ val raw = rawChannels.optJSONArray(channelIndex)
2576
+ FloatArray(existing.length) { sampleIndex ->
2577
+ val value = raw?.optDouble(sampleIndex, 0.0) ?: 0.0
2578
+ value.takeIf { it.isFinite() }?.toFloat()?.coerceIn(-1f, 1f) ?: 0f
2579
+ }
2580
+ }
2581
+ buffers[id] = existing.copy(channels = channels)
2199
2582
  }
2200
2583
  "connect" -> {
2201
2584
  val source = payload.optString("sourceId")
2202
- oscillators[source]?.gain = gains[payload.optString("destinationId")] ?: Parameter(1.0)
2585
+ val destination = payload.optString("destinationId", "destination")
2586
+ if (contextId.isNotBlank() && source.isNotBlank()) {
2587
+ inputs.getOrPut(key(contextId, destination)) { mutableSetOf() }.add(key(contextId, source))
2588
+ }
2203
2589
  }
2204
- "frequency" -> oscillators[payload.optString("nodeId")]?.frequency?.set(payload.optDouble("value", 0.0).coerceAtLeast(0.0))
2205
- "frequencyAtTime", "frequencyRampToTime" -> oscillators[payload.optString("nodeId")]?.frequency?.schedule(
2206
- payload.optDouble("value", 0.0).coerceAtLeast(0.0),
2207
- payload.optDouble("time", 0.0),
2208
- payload.optString("action") == "frequencyRampToTime",
2209
- )
2210
- "gain" -> gains[payload.optString("nodeId")]?.set(payload.optDouble("value", 0.0).coerceAtLeast(0.0))
2211
- "gainAtTime", "gainRampToTime" -> gains[payload.optString("nodeId")]?.schedule(
2212
- payload.optDouble("value", 0.0).coerceAtLeast(0.0),
2213
- payload.optDouble("time", 0.0),
2214
- payload.optString("action") == "gainRampToTime",
2215
- )
2216
- "start", "stop" -> {
2217
- val state = oscillators[payload.optString("nodeId")] ?: return
2590
+ "connectParam" -> {
2591
+ // Parameter modulation is accepted by the protocol. The
2592
+ // Android renderer keeps the target's scheduled value stable
2593
+ // rather than dropping the graph connection or throwing.
2594
+ }
2595
+ "type" -> node(contextId, payload.optString("nodeId"))?.waveform = payload.optInt("value", 0)
2596
+ "buffer" -> node(contextId, payload.optString("nodeId"))?.bufferId = payload.optString("bufferId").takeIf { it.isNotBlank() }
2597
+ "loop" -> node(contextId, payload.optString("nodeId"))?.let {
2598
+ it.bufferLoop = payload.optBoolean("loop", false)
2599
+ it.loopStart = payload.optDouble("loopStart", 0.0).coerceAtLeast(0.0)
2600
+ it.loopEnd = payload.optDouble("loopEnd", 0.0).coerceAtLeast(0.0)
2601
+ }
2602
+ "frequency", "gain", "detune", "playbackRate" -> {
2603
+ val state = node(contextId, payload.optString("nodeId")) ?: return
2604
+ parameter(state, payload.optString("action"))?.set(payload.optDouble("value", 0.0))
2605
+ }
2606
+ "frequencyAtTime", "frequencyRampToTime", "frequencyExponentialRampToTime",
2607
+ "gainAtTime", "gainRampToTime", "gainExponentialRampToTime",
2608
+ "detuneAtTime", "detuneRampToTime", "detuneExponentialRampToTime",
2609
+ "playbackRateAtTime", "playbackRateRampToTime", "playbackRateExponentialRampToTime",
2610
+ "gainTargetAtTime" -> {
2611
+ val state = node(contextId, payload.optString("nodeId")) ?: return
2612
+ val action = payload.optString("action")
2613
+ val parameterName = when {
2614
+ action.contains("TargetAtTime") -> action.substringBefore("TargetAtTime")
2615
+ action.contains("ExponentialRampToTime") -> action.substringBefore("ExponentialRampToTime")
2616
+ action.contains("RampToTime") -> action.substringBefore("RampToTime")
2617
+ else -> action.substringBefore("AtTime")
2618
+ }
2619
+ parameter(state, parameterName)?.schedule(
2620
+ payload.optDouble("value", 0.0), payload.optDouble("time", 0.0),
2621
+ when {
2622
+ action.contains("Exponential") -> "exponential"
2623
+ action.contains("Target") -> "target"
2624
+ action.contains("Ramp") -> "linear"
2625
+ else -> "set"
2626
+ },
2627
+ )
2628
+ }
2629
+ "start" -> {
2630
+ val state = node(contextId, payload.optString("nodeId")) ?: return
2218
2631
  ensureTrack()
2219
- val active = payload.optString("action") == "start"
2220
- val delay = (payload.optDouble("delay", 0.0).coerceAtLeast(0.0) * 1000).roundToLong()
2221
- handler.postDelayed({ state.active = active }, delay)
2632
+ val now = contextTime(contextId)
2633
+ state.startAt = now + payload.optDouble("delay", 0.0).coerceAtLeast(0.0)
2634
+ state.startOffset = payload.optDouble("offset", 0.0).coerceAtLeast(0.0)
2635
+ val duration = payload.optDouble("duration", -1.0)
2636
+ if (duration > 0) state.stopAt = state.startAt + duration
2637
+ }
2638
+ "stop" -> {
2639
+ val state = node(contextId, payload.optString("nodeId")) ?: return
2640
+ state.stopAt = contextTime(contextId) + payload.optDouble("delay", 0.0).coerceAtLeast(0.0)
2641
+ }
2642
+ "state" -> when (payload.optString("state")) {
2643
+ "suspended" -> suspended.add(contextId)
2644
+ "running" -> suspended.remove(contextId)
2645
+ "closed" -> closeContext(contextId)
2222
2646
  }
2223
2647
  "close" -> close()
2224
2648
  }
2225
2649
  }
2226
2650
 
2651
+ private fun parameter(state: NodeState, name: String): Parameter? = when (name) {
2652
+ "frequency" -> state.frequency
2653
+ "gain" -> state.gain
2654
+ "detune" -> state.detune
2655
+ "playbackRate" -> state.playbackRate
2656
+ else -> null
2657
+ }
2658
+
2659
+ private fun contextTime(contextId: String): Double {
2660
+ return (System.nanoTime() / 1_000_000_000.0) - (contextStarts[contextId] ?: return 0.0)
2661
+ }
2662
+
2663
+ private fun closeContext(contextId: String) {
2664
+ val prefix = "$contextId:"
2665
+ nodes.keys.removeAll { it.startsWith(prefix) }
2666
+ inputs.keys.removeAll { it.startsWith(prefix) }
2667
+ inputs.values.forEach { it.removeAll { source -> source.startsWith(prefix) } }
2668
+ contextStarts.remove(contextId)
2669
+ suspended.remove(contextId)
2670
+ }
2671
+
2227
2672
  @Synchronized fun close() {
2228
- oscillators.values.forEach { it.active = false }
2229
- oscillators.clear()
2230
- gains.clear()
2673
+ nodes.clear()
2674
+ inputs.clear()
2675
+ buffers.clear()
2231
2676
  contextStarts.clear()
2677
+ suspended.clear()
2232
2678
  running = false
2233
2679
  renderThread?.interrupt()
2234
2680
  renderThread = null
@@ -2269,31 +2715,66 @@ private class CraftNativeAudio {
2269
2715
 
2270
2716
  private fun render(output: AudioTrack, sampleRate: Int) {
2271
2717
  val buffer = ShortArray(1024 * 2)
2718
+ val stereo = DoubleArray(2)
2272
2719
  while (running && !Thread.currentThread().isInterrupted) {
2273
- val snapshot = synchronized(this) {
2274
- oscillators.mapValues { (id, state) ->
2275
- Triple(state, state.gain, state.active)
2276
- }
2277
- }
2278
- val now = System.nanoTime() / 1_000_000_000.0
2279
2720
  for (frame in 0 until 1024) {
2280
- var sample = 0.0
2281
- snapshot.forEach { (_, value) ->
2282
- val (state, gain, active) = value
2283
- if (!active) return@forEach
2284
- val time = (now - state.contextStart).coerceAtLeast(0.0) + frame.toDouble() / sampleRate
2285
- val frequency = state.frequency.valueAt(time)
2286
- sample += sin(state.phase) * gain.valueAt(time)
2287
- state.phase += 2 * PI * frequency / sampleRate
2288
- if (state.phase >= 2 * PI) state.phase -= 2 * PI
2721
+ synchronized(this) {
2722
+ stereo[0] = contextStarts.keys.sumOf { contextId ->
2723
+ if (contextId in suspended) 0.0
2724
+ else renderNode(key(contextId, "destination"), contextTime(contextId) + frame.toDouble() / sampleRate, 0, mutableSetOf())
2725
+ }
2726
+ stereo[1] = contextStarts.keys.sumOf { contextId ->
2727
+ if (contextId in suspended) 0.0
2728
+ else renderNode(key(contextId, "destination"), contextTime(contextId) + frame.toDouble() / sampleRate, 1, mutableSetOf())
2729
+ }
2289
2730
  }
2290
- val value = (sample.coerceIn(-1.0, 1.0) * Short.MAX_VALUE).toInt().toShort()
2291
- buffer[frame * 2] = value
2292
- buffer[frame * 2 + 1] = value
2731
+ buffer[frame * 2] = (stereo[0].coerceIn(-1.0, 1.0) * Short.MAX_VALUE).toInt().toShort()
2732
+ buffer[frame * 2 + 1] = (stereo[1].coerceIn(-1.0, 1.0) * Short.MAX_VALUE).toInt().toShort()
2293
2733
  }
2294
2734
  if (output.write(buffer, 0, buffer.size, AudioTrack.WRITE_BLOCKING) < 0) break
2295
2735
  }
2296
2736
  }
2737
+
2738
+ private fun renderNode(nodeKey: String, time: Double, channel: Int, visited: MutableSet<String>): Double {
2739
+ if (!visited.add(nodeKey)) return 0.0
2740
+ val state = nodes[nodeKey]
2741
+ var value = 0.0
2742
+ if (state == null || state.kind == "destination" || state.kind == "Gain" || state.kind == "BiquadFilter" || state.kind == "Delay" || state.kind == "DynamicsCompressor" || state.kind == "Convolver") {
2743
+ for (source in inputs[nodeKey].orEmpty()) value += renderNode(source, time, channel, visited)
2744
+ } else if (state.kind == "Oscillator") {
2745
+ if (time >= state.startAt && time < state.stopAt) {
2746
+ val elapsed = (time - state.startAt).coerceAtLeast(0.0)
2747
+ val frequency = (state.frequency.valueAt(time) * 2.0.pow(state.detune.valueAt(time) / 1200.0)).coerceAtLeast(0.0)
2748
+ val phase = (elapsed * frequency) % 1.0
2749
+ value = when (state.waveform) {
2750
+ 1 -> if (phase < 0.5) 1.0 else -1.0
2751
+ 2 -> 2.0 * phase - 1.0
2752
+ 3 -> 1.0 - 4.0 * kotlin.math.abs(phase - 0.5)
2753
+ else -> sin(phase * 2.0 * PI)
2754
+ }
2755
+ }
2756
+ } else if (state.kind == "BufferSource") {
2757
+ val buffer = state.bufferId?.let(buffers::get)
2758
+ if (buffer != null && time >= state.startAt && time < state.stopAt) {
2759
+ val elapsed = (time - state.startAt) * state.playbackRate.valueAt(time).coerceAtLeast(0.01)
2760
+ val length = buffer.length.toDouble() / buffer.sampleRate
2761
+ val loopEnd = if (state.loopEnd > state.loopStart) state.loopEnd else length
2762
+ val position = if (state.bufferLoop && loopEnd > state.loopStart) {
2763
+ state.loopStart + ((elapsed + state.startOffset - state.loopStart) % (loopEnd - state.loopStart))
2764
+ } else elapsed + state.startOffset
2765
+ if (state.bufferLoop || position < length) {
2766
+ val sampleIndex = (position * buffer.sampleRate).toLong().coerceAtLeast(0)
2767
+ value = (buffer.channels.getOrNull(channel) ?: buffer.channels.firstOrNull())
2768
+ ?.getOrNull(sampleIndex.toInt())
2769
+ ?.toDouble()
2770
+ ?: 0.0
2771
+ }
2772
+ }
2773
+ }
2774
+ if (state?.kind == "Gain") value *= state.gain.valueAt(time).coerceAtLeast(0.0)
2775
+ visited.remove(nodeKey)
2776
+ return value
2777
+ }
2297
2778
  }
2298
2779
 
2299
2780
  private class CraftTextDecorationSpan(
@@ -2576,6 +3057,16 @@ class MainActivity : AppCompatActivity() {
2576
3057
  }.getOrDefault(JSONObject())
2577
3058
  hapticsEnabled = config.optBoolean("enableHaptics", false)
2578
3059
  clipboardEnabled = config.optBoolean("enableClipboard", false)
3060
+ // Normally started already by its content provider; this covers a
3061
+ // process that skipped providers (instrumentation).
3062
+ CraftCrashReporter.start(this)
3063
+ if (BuildConfig.DEBUG && savedInstanceState == null) {
3064
+ CraftCrashReporter.debugCrashTestRequested(intent?.getStringExtra("craftCrashTest")) { message ->
3065
+ appScreen?.takeIf { it.active }?.let { app ->
3066
+ reportJavaScriptFailure(app, app.isolate.evaluateJavaScriptAsync("(function crashReporterTest() { throw new TypeError(${JSONObject.quote(message)}); })()"))
3067
+ }
3068
+ }
3069
+ }
2579
3070
  capabilities = CraftNativeCapabilities(this, config)
2580
3071
  routes = CraftNativeRoutes(config)
2581
3072
  stores = CraftNativeStores.get(this, config)
@@ -2904,18 +3395,36 @@ class MainActivity : AppCompatActivity() {
2904
3395
  audioMessage({ action: action, contextId: contextId, nodeId: nodeId, value: current });
2905
3396
  }
2906
3397
  });
2907
- function schedule(value, when, ramp) {
3398
+ param._paramOwner = nodeId;
3399
+ param._paramName = action;
3400
+ function schedule(value, when, suffix) {
2908
3401
  current = Number(value) || 0;
2909
3402
  var time = Number(when);
2910
- audioMessage({ action: action + (ramp ? 'RampToTime' : 'AtTime'), contextId: contextId, nodeId: nodeId, value: current, time: isFinite(time) ? Math.max(0, time) : 0 });
3403
+ audioMessage({ action: action + suffix, contextId: contextId, nodeId: nodeId, value: current, time: isFinite(time) ? Math.max(0, time) : 0 });
2911
3404
  }
2912
- param.setValueAtTime = function(value, when) { schedule(value, when, false); return param; };
2913
- param.linearRampToValueAtTime = function(value, when) { schedule(value, when, true); return param; };
3405
+ param.setValueAtTime = function(value, when) { schedule(value, when, 'AtTime'); return param; };
3406
+ param.linearRampToValueAtTime = function(value, when) { schedule(value, when, 'RampToTime'); return param; };
3407
+ param.exponentialRampToValueAtTime = function(value, when) { schedule(value, when, 'ExponentialRampToTime'); return param; };
3408
+ param.setTargetAtTime = function(value, when) { schedule(value, when, 'TargetAtTime'); return param; };
2914
3409
  return param;
2915
3410
  }
3411
+ function makeNode(context, kind) {
3412
+ var value = { _contextId: context._id, _nodeId: context._id + '-' + kind + '-' + (++nextAudioId) };
3413
+ value.connect = function(destination) {
3414
+ if (destination && destination._paramOwner) {
3415
+ audioMessage({ action: 'connectParam', contextId: context._id, sourceId: value._nodeId, destinationId: destination._paramOwner, parameter: destination._paramName || '' });
3416
+ } else {
3417
+ audioMessage({ action: 'connect', contextId: context._id, sourceId: value._nodeId, destinationId: destination && destination._nodeId || 'destination' });
3418
+ }
3419
+ return destination;
3420
+ };
3421
+ audioMessage({ action: 'create' + kind, contextId: context._id, nodeId: value._nodeId });
3422
+ return value;
3423
+ }
2916
3424
  function AudioContext() {
2917
3425
  this._id = 'audio-' + (++nextAudioId);
2918
3426
  this._started = Date.now() / 1000;
3427
+ this.sampleRate = 44100;
2919
3428
  this.state = 'running';
2920
3429
  this.destination = { _contextId: this._id, _nodeId: 'destination' };
2921
3430
  audioMessage({ action: 'createContext', contextId: this._id });
@@ -2925,37 +3434,101 @@ class MainActivity : AppCompatActivity() {
2925
3434
  });
2926
3435
  AudioContext.prototype.createOscillator = function() {
2927
3436
  var context = this;
2928
- var node = { _contextId: context._id, _nodeId: context._id + '-osc-' + (++nextAudioId) };
3437
+ var node = makeNode(context, 'Oscillator');
2929
3438
  node.frequency = audioParam(context._id, node._nodeId, 'frequency', 440);
2930
- node.connect = function(destination) {
2931
- audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2932
- return destination;
2933
- };
2934
3439
  node.start = function(when) {
2935
3440
  audioMessage({ action: 'start', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2936
3441
  };
2937
3442
  node.stop = function(when) {
2938
3443
  audioMessage({ action: 'stop', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2939
3444
  };
2940
- audioMessage({ action: 'createOscillator', contextId: context._id, nodeId: node._nodeId, frequency: node.frequency.value });
3445
+ audioMessage({ action: 'frequency', contextId: context._id, nodeId: node._nodeId, value: node.frequency.value });
2941
3446
  return node;
2942
3447
  };
2943
3448
  AudioContext.prototype.createGain = function() {
2944
3449
  var context = this;
2945
- var node = { _contextId: context._id, _nodeId: context._id + '-gain-' + (++nextAudioId) };
3450
+ var node = makeNode(context, 'Gain');
2946
3451
  node.gain = audioParam(context._id, node._nodeId, 'gain', 1);
2947
- node.connect = function(destination) {
2948
- audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2949
- return destination;
2950
- };
2951
- audioMessage({ action: 'createGain', contextId: context._id, nodeId: node._nodeId, gain: node.gain.value });
3452
+ audioMessage({ action: 'gain', contextId: context._id, nodeId: node._nodeId, value: node.gain.value });
2952
3453
  return node;
2953
3454
  };
2954
- AudioContext.prototype.resume = function() { this.state = 'running'; return Promise.resolve(); };
2955
- AudioContext.prototype.suspend = function() { this.state = 'suspended'; return Promise.resolve(); };
2956
- AudioContext.prototype.close = function() { this.state = 'closed'; audioMessage({ action: 'close', contextId: this._id }); return Promise.resolve(); };
3455
+ AudioContext.prototype.createBuffer = function(channels, length, sampleRate) {
3456
+ var buffer = { _id: 'buffer-' + (++nextAudioId), numberOfChannels: channels, length: length, sampleRate: sampleRate, duration: length / sampleRate, _channels: [] };
3457
+ for (var index = 0; index < channels; index++) buffer._channels.push(new Float32Array(length));
3458
+ buffer.getChannelData = function(channel) { return buffer._channels[channel]; };
3459
+ audioMessage({ action: 'createBuffer', contextId: this._id, bufferId: buffer._id, channels: channels, length: length, sampleRate: sampleRate });
3460
+ return buffer;
3461
+ };
3462
+ function uploadBuffer(contextId, buffer) {
3463
+ var channels = [];
3464
+ for (var index = 0; index < buffer._channels.length; index++) {
3465
+ var source = buffer._channels[index];
3466
+ var values = [];
3467
+ for (var sample = 0; sample < source.length; sample++) values.push(Number(source[sample]) || 0);
3468
+ channels.push(values);
3469
+ }
3470
+ audioMessage({ action: 'bufferData', contextId: contextId, bufferId: buffer._id, channels: channels });
3471
+ }
3472
+ AudioContext.prototype.createBufferSource = function() {
3473
+ var context = this;
3474
+ var value = makeNode(context, 'BufferSource');
3475
+ value.playbackRate = audioParam(context._id, value._nodeId, 'playbackRate', 1);
3476
+ value.detune = audioParam(context._id, value._nodeId, 'detune', 0);
3477
+ value.buffer = null;
3478
+ value.loop = false;
3479
+ value.loopStart = 0;
3480
+ value.loopEnd = 0;
3481
+ value.start = function(when, offset, duration) {
3482
+ if (value.buffer) {
3483
+ uploadBuffer(context._id, value.buffer);
3484
+ audioMessage({ action: 'buffer', contextId: context._id, nodeId: value._nodeId, bufferId: value.buffer._id });
3485
+ }
3486
+ audioMessage({ action: 'loop', contextId: context._id, nodeId: value._nodeId, loop: value.loop, loopStart: value.loopStart, loopEnd: value.loopEnd });
3487
+ audioMessage({ action: 'start', contextId: context._id, nodeId: value._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime), offset: Number(offset) || 0, duration: Number(duration) || -1 });
3488
+ };
3489
+ value.stop = function(when) { audioMessage({ action: 'stop', contextId: context._id, nodeId: value._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) }); };
3490
+ return value;
3491
+ };
3492
+ AudioContext.prototype.createBiquadFilter = function() {
3493
+ var context = this;
3494
+ var value = makeNode(context, 'BiquadFilter');
3495
+ value.frequency = audioParam(context._id, value._nodeId, 'frequency', 350);
3496
+ value.Q = audioParam(context._id, value._nodeId, 'Q', 1);
3497
+ value.gain = audioParam(context._id, value._nodeId, 'gain', 0);
3498
+ value.type = 'lowpass';
3499
+ return value;
3500
+ };
3501
+ AudioContext.prototype.createDelay = function() {
3502
+ var context = this;
3503
+ var value = makeNode(context, 'Delay');
3504
+ value.delayTime = audioParam(context._id, value._nodeId, 'delayTime', 0);
3505
+ return value;
3506
+ };
3507
+ AudioContext.prototype.createDynamicsCompressor = function() {
3508
+ var context = this;
3509
+ var value = makeNode(context, 'DynamicsCompressor');
3510
+ value.threshold = audioParam(context._id, value._nodeId, 'threshold', -24);
3511
+ value.knee = audioParam(context._id, value._nodeId, 'knee', 30);
3512
+ value.ratio = audioParam(context._id, value._nodeId, 'ratio', 12);
3513
+ value.attack = audioParam(context._id, value._nodeId, 'attack', 0.003);
3514
+ value.release = audioParam(context._id, value._nodeId, 'release', 0.25);
3515
+ value.reduction = 0;
3516
+ return value;
3517
+ };
3518
+ AudioContext.prototype.createConvolver = function() {
3519
+ var context = this;
3520
+ var value = makeNode(context, 'Convolver');
3521
+ value.normalize = true;
3522
+ value.buffer = null;
3523
+ return value;
3524
+ };
3525
+ AudioContext.prototype.resume = function() { this.state = 'running'; audioMessage({ action: 'state', contextId: this._id, state: this.state }); return Promise.resolve(); };
3526
+ AudioContext.prototype.suspend = function() { this.state = 'suspended'; audioMessage({ action: 'state', contextId: this._id, state: this.state }); return Promise.resolve(); };
3527
+ AudioContext.prototype.close = function() { this.state = 'closed'; audioMessage({ action: 'state', contextId: this._id, state: this.state }); audioMessage({ action: 'close', contextId: this._id }); return Promise.resolve(); };
2957
3528
  globalThis.AudioContext = AudioContext;
2958
3529
  globalThis.webkitAudioContext = AudioContext;
3530
+ globalThis.BaseAudioContext = AudioContext;
3531
+ globalThis.AudioBuffer = function() {};
2959
3532
  }
2960
3533
  })()
2961
3534
  """.trimIndent()
@@ -3029,6 +3602,7 @@ class MainActivity : AppCompatActivity() {
3029
3602
  }
3030
3603
  } catch (error: Exception) {
3031
3604
  Log.e(TAG, "Native STX bundle failed", error)
3605
+ reportJavaScriptError(activeScreen, error)
3032
3606
  if (activeScreen.active) showError("Native STX bundle failed: ${error.message}")
3033
3607
  }
3034
3608
  }, executor)
@@ -3046,6 +3620,8 @@ class MainActivity : AppCompatActivity() {
3046
3620
  * screen shown before hears true (its `navigation.onAppear`).
3047
3621
  */
3048
3622
  private fun show(screen: Screen) {
3623
+ // A navigation breadcrumb: the trail a crash report carries.
3624
+ if (visibleScreen !== screen) CraftCrashReporter.screenAppeared(screen.route ?: "home")
3049
3625
  val bars = chrome
3050
3626
  val host: ViewGroup = bars?.content ?: root
3051
3627
  host.removeAllViews()
@@ -3258,8 +3834,13 @@ class MainActivity : AppCompatActivity() {
3258
3834
  }
3259
3835
  "NAVIGATE_OPEN" -> payload.optString("path").takeIf { it.isNotBlank() }?.let(::navigateOpen)
3260
3836
  "NAVIGATION_SET_OPTIONS" -> {
3261
- payload.keys().forEach { key -> screen.options.put(key, payload.opt(key)) }
3262
- if (visibleScreen === screen) applyChrome(screen)
3837
+ // The app script has no navigation bar of its own. Screen
3838
+ // options remain valid while that screen is off-stack, so
3839
+ // only reject the headless app isolate here.
3840
+ if (screen !== appScreen) {
3841
+ payload.keys().forEach { key -> screen.options.put(key, payload.opt(key)) }
3842
+ if (visibleScreen === screen) applyChrome(screen)
3843
+ }
3263
3844
  }
3264
3845
  "SYNC_WRITE" -> syncWrite(screen, payload)
3265
3846
  "TIMER" -> {
@@ -3275,15 +3856,20 @@ class MainActivity : AppCompatActivity() {
3275
3856
  "TIMER_CLEAR" -> screen.timers.remove(payload.optInt("id"))?.let(capabilityHandler::removeCallbacks)
3276
3857
  "FRAME" -> requestFrame(screen)
3277
3858
  "NAVIGATE", "NAVIGATE_REPLACE" -> {
3278
- if (screens.lastOrNull() !== screen && screen !== appScreen) return
3859
+ // Navigation belongs to the route that is actually visible.
3860
+ // The stack list can be temporarily detached while a tab or
3861
+ // back transition restores its root; using its last entry
3862
+ // silently drops a valid push from that visible screen. The
3863
+ // app script has no stack of its own; it uses NAVIGATE_OPEN.
3864
+ if (visibleScreen !== screen) return
3279
3865
  val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
3280
3866
  if (message.optString("type") == "NAVIGATE_REPLACE") {
3281
3867
  closeScreen(screens.removeAt(screens.lastIndex))
3282
3868
  }
3283
3869
  openScreen(route, payload.optJSONObject("params") ?: JSONObject())
3284
3870
  }
3285
- "NAVIGATE_BACK" -> if (screens.lastOrNull() === screen || screen === appScreen) back()
3286
- "NAVIGATE_POP_TO_ROOT" -> if (screens.lastOrNull() === screen || screen === appScreen) popToRoot()
3871
+ "NAVIGATE_BACK" -> if (visibleScreen === screen) back()
3872
+ "NAVIGATE_POP_TO_ROOT" -> if (visibleScreen === screen) popToRoot()
3287
3873
  "API_REQUEST" -> answerAPI(screen, message, payload)
3288
3874
  "API_CANCEL" -> {
3289
3875
  val id = payload.optString("requestId")
@@ -3324,7 +3910,7 @@ class MainActivity : AppCompatActivity() {
3324
3910
  private fun applyTargetedUpdates(screen: Screen, ids: List<String>): Boolean {
3325
3911
  val applied = mutableSetOf<String>()
3326
3912
  for (id in ids) {
3327
- val target = screen.flatListOwners[id] ?: id
3913
+ val target = drawnTarget(screen, id) ?: return false
3328
3914
  if (!applied.add(target)) continue
3329
3915
  val document = screen.mutations.node(target) ?: return false
3330
3916
  val path = screen.nodePaths[target] ?: return false
@@ -3340,6 +3926,17 @@ class MainActivity : AppCompatActivity() {
3340
3926
  return true
3341
3927
  }
3342
3928
 
3929
+ /** Find the nearest rendered ancestor for protocol nodes with no view. */
3930
+ private fun drawnTarget(screen: Screen, id: String): String? {
3931
+ var current: String? = id
3932
+ while (current != null) {
3933
+ val target = screen.flatListOwners[current] ?: current
3934
+ if (screen.nodeViews[target] != null) return target
3935
+ current = screen.mutations.parentId(current)
3936
+ }
3937
+ return null
3938
+ }
3939
+
3343
3940
  private fun scheduleLayoutEvents(screen: Screen) {
3344
3941
  if (!screen.active) return
3345
3942
  val observer = screen.view.viewTreeObserver
@@ -3478,8 +4075,8 @@ class MainActivity : AppCompatActivity() {
3478
4075
  configureText(this, childText(children), style)
3479
4076
  }
3480
4077
  "StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3481
- screen.statusBarStyle = props.optString("barStyle").takeIf { it.isNotBlank() }
3482
- screen.statusBarHidden = if (props.has("hidden")) props.optBoolean("hidden") else null
4078
+ screen.statusBarStyle = craftNativeOptionalString(props.opt("barStyle"))
4079
+ screen.statusBarHidden = if (props.has("hidden") && !props.isNull("hidden")) props.optBoolean("hidden") else null
3483
4080
  visibility = View.GONE
3484
4081
  }
3485
4082
  "RefreshControl" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
@@ -3487,19 +4084,19 @@ class MainActivity : AppCompatActivity() {
3487
4084
  }
3488
4085
  "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
3489
4086
  includeFontPadding = false
3490
- configureText(this, props.optString("title").ifBlank { childText(children) }, style)
3491
- val linkColor = props.optString("color").takeIf { it.isNotBlank() } ?: style.optString("color")
4087
+ configureText(this, craftNativeOptionalString(props.opt("title")) ?: childText(children), style)
4088
+ val linkColor = craftNativeOptionalString(props.opt("color")) ?: craftNativeOptionalString(style.opt("color"))
3492
4089
  setTextColor(color(linkColor, Color.rgb(33, 150, 243)))
3493
4090
  paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG
3494
4091
  isEnabled = !props.optBoolean("disabled", false)
3495
4092
  isClickable = true
3496
4093
  isFocusable = true
3497
- val target = listOf(props.optString("href"), props.optString("to"))
3498
- .firstOrNull { it.isNotBlank() }
4094
+ val target = listOf(props.opt("href"), props.opt("to"))
4095
+ .firstNotNullOfOrNull(::craftNativeOptionalString)
3499
4096
  ?.trim()
3500
4097
  ?.takeIf { !props.optBoolean("disabled", false) }
3501
4098
  if (target != null) screen.linkTargets[this] = target else screen.linkTargets.remove(this)
3502
- val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
4099
+ val handler = craftNativeEventName(events, "onPress", "onClick")
3503
4100
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3504
4101
  setOnClickListener {
3505
4102
  screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
@@ -3518,12 +4115,12 @@ class MainActivity : AppCompatActivity() {
3518
4115
  minimumHeight = 0
3519
4116
  setPadding(0, 0, 0, 0)
3520
4117
  }).apply {
3521
- val title = props.optString("title").ifBlank { childText(children) }
4118
+ val title = craftNativeOptionalString(props.opt("title")) ?: childText(children)
3522
4119
  isAllCaps = false
3523
4120
  configureText(this, title, style)
3524
- props.optString("color").takeIf { it.isNotBlank() }?.let { setTextColor(color(it, defaultTextColor)) }
4121
+ craftNativeOptionalString(props.opt("color"))?.let { setTextColor(color(it, defaultTextColor)) }
3525
4122
  isEnabled = !props.optBoolean("disabled", false)
3526
- val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
4123
+ val handler = craftNativeEventName(events, "onPress", "onClick")
3527
4124
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3528
4125
  setOnClickListener {
3529
4126
  val current = screen.handlers[this] ?: return@setOnClickListener
@@ -3531,7 +4128,7 @@ class MainActivity : AppCompatActivity() {
3531
4128
  }
3532
4129
  }
3533
4130
  "Checkbox" -> (previous as? CheckBox ?: CheckBox(this)).apply {
3534
- text = props.optString("label").ifBlank { childText(children) }
4131
+ text = craftNativeOptionalString(props.opt("label")) ?: childText(children)
3535
4132
  configureText(this, text.toString(), style)
3536
4133
  val requested = props.opt("checked") ?: props.opt("value")
3537
4134
  val checked = when (requested) {
@@ -3547,8 +4144,10 @@ class MainActivity : AppCompatActivity() {
3547
4144
  isChecked = checked
3548
4145
  isActivated = props.optBoolean("indeterminate", false)
3549
4146
  isEnabled = !props.optBoolean("disabled", false)
3550
- props.optString("color").takeIf { it.isNotBlank() }?.let { buttonTintList = ColorStateList.valueOf(color(it, defaultTextColor)) }
3551
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
4147
+ val checkboxColor = craftNativeOptionalString(props.opt("color"))
4148
+ ?: craftNativeOptionalString(style.opt("color"))
4149
+ buttonTintList = checkboxColor?.let { ColorStateList.valueOf(color(it, defaultTextColor)) }
4150
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
3552
4151
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3553
4152
  setOnCheckedChangeListener { _, value ->
3554
4153
  screen.handlers[this]?.let { current ->
@@ -3569,13 +4168,13 @@ class MainActivity : AppCompatActivity() {
3569
4168
  hintTextColors ?: ColorStateList.valueOf(Color.GRAY)
3570
4169
  }
3571
4170
  val defaultHighlightColor = inputHighlightDefaults.getOrPut(this) { highlightColor }
3572
- hint = props.optString("placeholder")
4171
+ hint = craftNativeOptionalString(props.opt("placeholder")).orEmpty()
3573
4172
  setHintTextColor(
3574
- props.optString("placeholderTextColor").takeIf { it.isNotBlank() }
4173
+ craftNativeOptionalString(props.opt("placeholderTextColor"))
3575
4174
  ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
3576
4175
  ?: defaultHintColors,
3577
4176
  )
3578
- highlightColor = props.optString("selectionColor").takeIf { it.isNotBlank() }
4177
+ highlightColor = craftNativeOptionalString(props.opt("selectionColor"))
3579
4178
  ?.let { color(it, defaultHighlightColor) }
3580
4179
  ?: defaultHighlightColor
3581
4180
  val maxLength = props.optInt("maxLength", 0)
@@ -3597,14 +4196,14 @@ class MainActivity : AppCompatActivity() {
3597
4196
  // the web; without one the field keeps its own.
3598
4197
  if (listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom").any { style.has(it) }) {
3599
4198
  val density = resources.displayMetrics.density
3600
- val all = style.optDouble("padding", 0.0)
3601
- val horizontal = style.optDouble("paddingHorizontal", all)
3602
- val vertical = style.optDouble("paddingVertical", all)
4199
+ val all = craftNativePadding(style, "padding", 0, density)
4200
+ val horizontal = craftNativePadding(style, "paddingHorizontal", all, density)
4201
+ val vertical = craftNativePadding(style, "paddingVertical", all, density)
3603
4202
  setPadding(
3604
- (style.optDouble("paddingLeft", horizontal) * density).toInt(),
3605
- (style.optDouble("paddingTop", vertical) * density).toInt(),
3606
- (style.optDouble("paddingRight", horizontal) * density).toInt(),
3607
- (style.optDouble("paddingBottom", vertical) * density).toInt(),
4203
+ craftNativePadding(style, "paddingLeft", horizontal, density),
4204
+ craftNativePadding(style, "paddingTop", vertical, density),
4205
+ craftNativePadding(style, "paddingRight", horizontal, density),
4206
+ craftNativePadding(style, "paddingBottom", vertical, density),
3608
4207
  )
3609
4208
  }
3610
4209
  // Reused EditTexts must forget the previous field's hint as
@@ -3625,15 +4224,15 @@ class MainActivity : AppCompatActivity() {
3625
4224
  else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) setText(props.optString("defaultValue"))
3626
4225
  applyTextDecoration(this, style)
3627
4226
  screen.inputIdentities[this] = identity
3628
- val handler = events.optString("onChange").ifBlank { events.optString("onChangeText") }
4227
+ val handler = craftNativeEventName(events, "onChange", "onChangeText")
3629
4228
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3630
- val onFocus = events.optString("onFocus")
4229
+ val onFocus = craftNativeEventName(events, "onFocus")
3631
4230
  if (onFocus.isNotBlank()) screen.focusHandlers[this] = onFocus else screen.focusHandlers.remove(this)
3632
- val onBlur = events.optString("onBlur")
4231
+ val onBlur = craftNativeEventName(events, "onBlur")
3633
4232
  if (onBlur.isNotBlank()) screen.blurHandlers[this] = onBlur else screen.blurHandlers.remove(this)
3634
- val onEndEditing = events.optString("onEndEditing")
4233
+ val onEndEditing = craftNativeEventName(events, "onEndEditing")
3635
4234
  if (onEndEditing.isNotBlank()) screen.endEditingHandlers[this] = onEndEditing else screen.endEditingHandlers.remove(this)
3636
- val onSubmit = events.optString("onSubmitEditing")
4235
+ val onSubmit = craftNativeEventName(events, "onSubmitEditing")
3637
4236
  if (onSubmit.isNotBlank()) screen.submitHandlers[this] = onSubmit else screen.submitHandlers.remove(this)
3638
4237
  setOnFocusChangeListener { view, focused ->
3639
4238
  val nativeEvent = JSONObject().put("text", text.toString())
@@ -3694,10 +4293,10 @@ class MainActivity : AppCompatActivity() {
3694
4293
  }
3695
4294
  if (label.isBlank()) return@mapNotNull null
3696
4295
  val optionProps = option.optJSONObject("props")
3697
- val value = optionProps?.optString("value").takeIf { !it.isNullOrBlank() } ?: label
4296
+ val value = craftNativeOptionalString(optionProps?.opt("value")) ?: label
3698
4297
  value to label
3699
4298
  }
3700
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
4299
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
3701
4300
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3702
4301
  // Detach the old listener before replacing the adapter or selection.
3703
4302
  // Both operations can synchronously dispatch an Android selection
@@ -3706,7 +4305,8 @@ class MainActivity : AppCompatActivity() {
3706
4305
  adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_spinner_item, options.map { it.second }).also {
3707
4306
  it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
3708
4307
  }
3709
- val requested = props.optString("selectedValue").ifBlank { props.optString("value") }
4308
+ val requested = craftNativeOptionalString(props.opt("selectedValue"))
4309
+ ?: craftNativeOptionalString(props.opt("value")).orEmpty()
3710
4310
  val selected = options.indexOfFirst { it.first == requested }.takeIf { it >= 0 } ?: 0
3711
4311
  isEnabled = !props.optBoolean("disabled", false)
3712
4312
  // Mounting a controlled picker must not look like a user
@@ -3729,7 +4329,7 @@ class MainActivity : AppCompatActivity() {
3729
4329
  }
3730
4330
  "DatePicker" -> (previous as? CraftNativeDatePickerView ?: CraftNativeDatePickerView(this)).apply {
3731
4331
  configure(props)
3732
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
4332
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
3733
4333
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3734
4334
  onValueChanged = {
3735
4335
  screen.handlers[this]?.let { current ->
@@ -3743,7 +4343,7 @@ class MainActivity : AppCompatActivity() {
3743
4343
  reconcileChildren(screen, content, children, path, next, style, previousControls)
3744
4344
  // Dynamic :visible bindings arrive as a display style mutation;
3745
4345
  // static visible props remain authoritative when present.
3746
- val visible = if (props.has("visible")) {
4346
+ val visible = if (props.has("visible") && !props.isNull("visible")) {
3747
4347
  // An explicit visible prop owns modal visibility. The
3748
4348
  // binding runtime may leave a stale display:none style
3749
4349
  // from the previous hidden state when it patches props.
@@ -3753,18 +4353,22 @@ class MainActivity : AppCompatActivity() {
3753
4353
  }
3754
4354
  val wasVisible = screen.modalVisibility[this]
3755
4355
  screen.modalVisibility[this] = visible
3756
- val requestClose = events.optString("onRequestClose")
4356
+ val requestClose = craftNativeOptionalString(events.opt("onRequestClose")).orEmpty()
3757
4357
  if (requestClose.isNotBlank()) screen.modalCloseHandlers[this] = requestClose else screen.modalCloseHandlers.remove(this)
3758
4358
  onRequestClose = if (requestClose.isNotBlank()) {
3759
4359
  { send(screen, "EVENT", JSONObject().put("handlerName", requestClose).put("nativeEvent", JSONObject())) }
3760
4360
  } else {
3761
4361
  null
3762
4362
  }
3763
- setVisible(visible, style.optDouble("opacity", 1.0).toFloat(), props.optString("animationType").ifBlank { null })
4363
+ setVisible(visible, craftNativeFiniteFloat(style, "opacity", 1f), craftNativeOptionalString(props.opt("animationType")))
3764
4364
  if (visible) clearDismissSuppression()
3765
4365
  val suppressDismiss = !visible && consumeDismissSuppression()
3766
4366
  if (wasVisible != visible && (visible || wasVisible == true)) {
3767
- val lifecycleHandler = if (visible) events.optString("onShow") else events.optString("onDismiss")
4367
+ val lifecycleHandler = if (visible) {
4368
+ craftNativeOptionalString(events.opt("onShow"))
4369
+ } else {
4370
+ craftNativeOptionalString(events.opt("onDismiss"))
4371
+ }
3768
4372
  if (lifecycleHandler.isNotBlank() && !suppressDismiss) {
3769
4373
  // Deliver lifecycle callbacks after reconciliation returns to
3770
4374
  // the queue; the handler may immediately schedule another
@@ -3779,8 +4383,8 @@ class MainActivity : AppCompatActivity() {
3779
4383
  }
3780
4384
  "TouchableOpacity", "TouchableHighlight", "Pressable" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3781
4385
  configureContainer(this, style)
3782
- screen.pressActiveOpacities[this] = props.optDouble("activeOpacity", 0.5).toFloat()
3783
- screen.pressBaseOpacities[this] = style.optDouble("opacity", 1.0).toFloat()
4386
+ screen.pressActiveOpacities[this] = craftNativeFiniteFloat(props, "activeOpacity", 0.5f)
4387
+ screen.pressBaseOpacities[this] = craftNativeFiniteFloat(style, "opacity", 1f)
3784
4388
  reconcileChildren(screen, this, children, path, next, style, previousControls)
3785
4389
  }
3786
4390
  "Switch" -> (previous as? Switch ?: Switch(this)).apply {
@@ -3804,9 +4408,10 @@ class MainActivity : AppCompatActivity() {
3804
4408
  intArrayOf(trueColor, falseColor),
3805
4409
  )
3806
4410
  } ?: run { trackTintList = null }
3807
- thumbTintList = props.optString("thumbColor").takeIf { it.isNotBlank() }
4411
+ craftNativeOptionalString(props.opt("thumbColor"))
3808
4412
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
3809
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
4413
+ ?: run { thumbTintList = null }
4414
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
3810
4415
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3811
4416
  setOnCheckedChangeListener { _, checked ->
3812
4417
  val current = screen.handlers[this] ?: return@setOnCheckedChangeListener
@@ -3814,11 +4419,11 @@ class MainActivity : AppCompatActivity() {
3814
4419
  }
3815
4420
  }
3816
4421
  "Slider" -> (previous as? SeekBar ?: SeekBar(this)).apply {
3817
- val minimum = props.optDouble("minimumValue", 0.0)
3818
- val maximumCandidate = props.optDouble("maximumValue", 1.0)
4422
+ val minimum = craftNativeFiniteDouble(props, "minimumValue", 0.0)
4423
+ val maximumCandidate = craftNativeFiniteDouble(props, "maximumValue", 1.0)
3819
4424
  val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
3820
4425
  setOnSeekBarChangeListener(null)
3821
- val step = props.optDouble("step", 0.0).takeIf { it > 0 }
4426
+ val step = craftNativeFiniteDouble(props, "step", 0.0).takeIf { it > 0 }
3822
4427
  // A stepped slider needs one native position per authored
3823
4428
  // step; a fixed 1,000-position SeekBar silently made large
3824
4429
  // ranges unable to represent most valid values. Continuous
@@ -3832,7 +4437,7 @@ class MainActivity : AppCompatActivity() {
3832
4437
  .toInt()
3833
4438
  } ?: 1000
3834
4439
  max = progressMax
3835
- val requested = props.optDouble("value", minimum)
4440
+ val requested = craftNativeFiniteDouble(props, "value", minimum)
3836
4441
  val value = (if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested)
3837
4442
  .coerceIn(minimum, maximum)
3838
4443
  progress = if (step != null) {
@@ -3841,16 +4446,19 @@ class MainActivity : AppCompatActivity() {
3841
4446
  (((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
3842
4447
  }
3843
4448
  if (step != null) screen.sliderSteps[this] = step else screen.sliderSteps.remove(this)
3844
- progressTintList = props.optString("minimumTrackTintColor").takeIf { it.isNotBlank() }
4449
+ craftNativeOptionalString(props.opt("minimumTrackTintColor"))
3845
4450
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
3846
- progressBackgroundTintList = props.optString("maximumTrackTintColor").takeIf { it.isNotBlank() }
4451
+ ?: run { progressTintList = null }
4452
+ craftNativeOptionalString(props.opt("maximumTrackTintColor"))
3847
4453
  ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
3848
- thumbTintList = props.optString("thumbTintColor").takeIf { it.isNotBlank() }
4454
+ ?: run { progressBackgroundTintList = null }
4455
+ craftNativeOptionalString(props.opt("thumbTintColor"))
3849
4456
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
4457
+ ?: run { thumbTintList = null }
3850
4458
  isEnabled = !props.optBoolean("disabled", false)
3851
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
4459
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
3852
4460
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3853
- val complete = events.optString("onSlidingComplete")
4461
+ val complete = craftNativeEventName(events, "onSlidingComplete")
3854
4462
  if (complete.isNotBlank()) screen.sliderCompleteHandlers[this] = complete else screen.sliderCompleteHandlers.remove(this)
3855
4463
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
3856
4464
  override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
@@ -3877,7 +4485,7 @@ class MainActivity : AppCompatActivity() {
3877
4485
  val scale = when (requestedSize) {
3878
4486
  "large" -> 1.35f
3879
4487
  "small" -> 0.75f
3880
- is Number -> (requestedSize.toDouble() / 24.0).toFloat().coerceAtLeast(0.5f)
4488
+ is Number -> (requestedSize.toDouble() / 24.0).takeIf { it.isFinite() }?.toFloat()?.coerceAtLeast(0.5f) ?: 1f
3881
4489
  else -> 1f
3882
4490
  }
3883
4491
  scaleX = scale
@@ -3885,44 +4493,45 @@ class MainActivity : AppCompatActivity() {
3885
4493
  val animating = props.optBoolean("animating", true)
3886
4494
  isIndeterminate = animating
3887
4495
  visibility = if (!animating && props.optBoolean("hidesWhenStopped", true)) View.GONE else View.VISIBLE
3888
- indeterminateTintList = props.optString("color").takeIf { it.isNotBlank() }
4496
+ craftNativeOptionalString(props.opt("color"))
3889
4497
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
4498
+ ?: run { indeterminateTintList = null }
3890
4499
  }
3891
4500
  "Icon" -> (previous as? CraftNativeIconView ?: CraftNativeIconView(this)).apply {
3892
4501
  configureIcon(this, props, style)
3893
4502
  }
3894
4503
  "Image" -> (previous as? ImageView ?: ImageView(this)).apply {
3895
- scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
4504
+ scaleType = when (craftNativeResizeMode(style, props)) {
3896
4505
  "cover" -> ImageView.ScaleType.CENTER_CROP
3897
4506
  "stretch" -> ImageView.ScaleType.FIT_XY
3898
4507
  "center" -> ImageView.ScaleType.CENTER
3899
4508
  else -> ImageView.ScaleType.CENTER_INSIDE
3900
4509
  }
3901
4510
  adjustViewBounds = false
3902
- val onLoadStart = events.optString("onLoadStart")
4511
+ val onLoadStart = craftNativeEventName(events, "onLoadStart")
3903
4512
  if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[this] = onLoadStart else screen.imageLoadStartHandlers.remove(this)
3904
- val onLoad = events.optString("onLoad")
4513
+ val onLoad = craftNativeEventName(events, "onLoad")
3905
4514
  if (onLoad.isNotBlank()) screen.imageLoadHandlers[this] = onLoad else screen.imageLoadHandlers.remove(this)
3906
- val onLoadEnd = events.optString("onLoadEnd")
4515
+ val onLoadEnd = craftNativeEventName(events, "onLoadEnd")
3907
4516
  if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[this] = onLoadEnd else screen.imageLoadEndHandlers.remove(this)
3908
- val onError = events.optString("onError")
4517
+ val onError = craftNativeEventName(events, "onError")
3909
4518
  if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
3910
4519
  updateImage(screen, this, props.opt("source"), props.opt("defaultSource"))
3911
4520
  }
3912
4521
  "ImageBackground" -> (previous as? CraftNativeImageBackgroundView ?: CraftNativeImageBackgroundView(this)).apply {
3913
- image.scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
4522
+ image.scaleType = when (craftNativeResizeMode(style, props)) {
3914
4523
  "cover" -> ImageView.ScaleType.CENTER_CROP
3915
4524
  "stretch" -> ImageView.ScaleType.FIT_XY
3916
4525
  "center" -> ImageView.ScaleType.CENTER
3917
4526
  else -> ImageView.ScaleType.CENTER_INSIDE
3918
4527
  }
3919
- val onLoadStart = events.optString("onLoadStart")
4528
+ val onLoadStart = craftNativeEventName(events, "onLoadStart")
3920
4529
  if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[image] = onLoadStart else screen.imageLoadStartHandlers.remove(image)
3921
- val onLoad = events.optString("onLoad")
4530
+ val onLoad = craftNativeEventName(events, "onLoad")
3922
4531
  if (onLoad.isNotBlank()) screen.imageLoadHandlers[image] = onLoad else screen.imageLoadHandlers.remove(image)
3923
- val onLoadEnd = events.optString("onLoadEnd")
4532
+ val onLoadEnd = craftNativeEventName(events, "onLoadEnd")
3924
4533
  if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[image] = onLoadEnd else screen.imageLoadEndHandlers.remove(image)
3925
- val onError = events.optString("onError")
4534
+ val onError = craftNativeEventName(events, "onError")
3926
4535
  if (onError.isNotBlank()) screen.imageErrorHandlers[image] = onError else screen.imageErrorHandlers.remove(image)
3927
4536
  updateImage(screen, image, props.opt("source"), props.opt("defaultSource"))
3928
4537
  configureContainer(content, style)
@@ -3936,6 +4545,7 @@ class MainActivity : AppCompatActivity() {
3936
4545
  loop = props.optBoolean("loop", false),
3937
4546
  muted = props.optBoolean("muted", false),
3938
4547
  controls = props.optBoolean("controls", false),
4548
+ resizeMode = craftNativeResizeMode(style, props),
3939
4549
  )
3940
4550
  }
3941
4551
  "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
@@ -3969,22 +4579,23 @@ class MainActivity : AppCompatActivity() {
3969
4579
  .firstOrNull { canonicalType(it.optString("type")) == "RefreshControl" }
3970
4580
  val refreshProps = refreshNode?.optJSONObject("props") ?: JSONObject()
3971
4581
  val refreshEvents = refreshNode?.optJSONObject("events") ?: JSONObject()
3972
- val refreshHandlerName = refreshEvents.optString("onRefresh").takeIf { it.isNotBlank() }
3973
- ?: events.optString("onRefresh").takeIf { it.isNotBlank() }
4582
+ val refreshHandlerName = craftNativeEventName(refreshEvents, "onRefresh")
4583
+ .takeIf { it.isNotBlank() }
4584
+ ?: craftNativeEventName(events, "onRefresh").takeIf { it.isNotBlank() }
3974
4585
  val refreshHandler = refreshHandlerName?.let { handler ->
3975
4586
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
3976
4587
  }
3977
4588
  setRefreshHandler(refreshHandler, if (refreshNode == null) props.optBoolean("refreshing", false) else refreshProps.optBoolean("refreshing", false))
3978
4589
  setRefreshAppearance(refreshProps, resources.displayMetrics.density)
3979
- val onScroll = events.optString("onScroll")
4590
+ val onScroll = craftNativeEventName(events, "onScroll")
3980
4591
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
3981
- val onBegin = events.optString("onScrollBeginDrag")
4592
+ val onBegin = craftNativeEventName(events, "onScrollBeginDrag")
3982
4593
  if (onBegin.isNotBlank()) screen.scrollBeginHandlers[this] = onBegin else screen.scrollBeginHandlers.remove(this)
3983
- val onEnd = events.optString("onScrollEndDrag")
4594
+ val onEnd = craftNativeEventName(events, "onScrollEndDrag")
3984
4595
  if (onEnd.isNotBlank()) screen.scrollEndHandlers[this] = onEnd else screen.scrollEndHandlers.remove(this)
3985
- val onMomentumBegin = events.optString("onMomentumScrollBegin")
4596
+ val onMomentumBegin = craftNativeEventName(events, "onMomentumScrollBegin")
3986
4597
  if (onMomentumBegin.isNotBlank()) screen.scrollMomentumBeginHandlers[this] = onMomentumBegin else screen.scrollMomentumBeginHandlers.remove(this)
3987
- val onMomentumEnd = events.optString("onMomentumScrollEnd")
4598
+ val onMomentumEnd = craftNativeEventName(events, "onMomentumScrollEnd")
3988
4599
  if (onMomentumEnd.isNotBlank()) screen.scrollMomentumEndHandlers[this] = onMomentumEnd else screen.scrollMomentumEndHandlers.remove(this)
3989
4600
  val nativeScroll = this
3990
4601
  setScrollListeners(
@@ -4038,7 +4649,7 @@ class MainActivity : AppCompatActivity() {
4038
4649
  }
4039
4650
  "FlatList" -> (previous as? CraftNativeRefreshableFlatList ?: CraftNativeRefreshableFlatList(this)).apply {
4040
4651
  val nativeList = list
4041
- configureRefresh(events.optString("onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
4652
+ configureRefresh(craftNativeEventName(events, "onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
4042
4653
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
4043
4654
  }, props.optBoolean("refreshing", false))
4044
4655
  nativeList.setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
@@ -4070,15 +4681,15 @@ class MainActivity : AppCompatActivity() {
4070
4681
  } else if (control is CraftNativeKeyboardAvoidingView) {
4071
4682
  control.content.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
4072
4683
  }
4073
- val pressInHandler = events.optString("onPressIn").takeIf { it.isNotBlank() }
4074
- val pressOutHandler = events.optString("onPressOut").takeIf { it.isNotBlank() }
4684
+ val pressInHandler = craftNativeEventName(events, "onPressIn").takeIf { it.isNotBlank() }
4685
+ val pressOutHandler = craftNativeEventName(events, "onPressOut").takeIf { it.isNotBlank() }
4075
4686
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
4076
4687
  if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
4077
4688
  if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
4078
- val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
4689
+ val handler = craftNativeEventName(events, "onPress", "onClick")
4079
4690
  val disabled = props.optBoolean("disabled", false)
4080
- val target = listOf(props.optString("href"), props.optString("to"))
4081
- .firstOrNull { it.isNotBlank() }
4691
+ val target = listOf(props.opt("href"), props.opt("to"))
4692
+ .firstNotNullOfOrNull(::craftNativeOptionalString)
4082
4693
  ?.trim()
4083
4694
  ?.takeIf { !disabled && type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") }
4084
4695
  if (target != null) screen.linkTargets[control] = target else screen.linkTargets.remove(control)
@@ -4139,7 +4750,7 @@ class MainActivity : AppCompatActivity() {
4139
4750
  }
4140
4751
  val longPressDisabled = props.optBoolean("disabled", false) ||
4141
4752
  props.optJSONObject("accessibilityState")?.optBoolean("disabled", false) == true
4142
- val longPressHandler = events.optString("onLongPress").takeUnless { longPressDisabled } ?: ""
4753
+ val longPressHandler = craftNativeEventName(events, "onLongPress").takeUnless { longPressDisabled } ?: ""
4143
4754
  if (longPressHandler.isNotBlank()) {
4144
4755
  screen.longPressHandlers[control] = longPressHandler
4145
4756
  control.setOnLongClickListener {
@@ -4153,7 +4764,7 @@ class MainActivity : AppCompatActivity() {
4153
4764
  control.isLongClickable = false
4154
4765
  }
4155
4766
  applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
4156
- val layoutHandler = events.optString("onLayout")
4767
+ val layoutHandler = craftNativeEventName(events, "onLayout")
4157
4768
  if (layoutHandler.isNotBlank()) {
4158
4769
  if (screen.layoutHandlers[control] != layoutHandler) screen.lastLayoutBounds.remove(control)
4159
4770
  screen.layoutHandlers[control] = layoutHandler
@@ -4189,20 +4800,20 @@ class MainActivity : AppCompatActivity() {
4189
4800
  View.VISIBLE
4190
4801
  }
4191
4802
  }
4192
- val width = style.optDouble("width", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
4193
- val height = style.optDouble("height", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
4803
+ val width = craftNativeFiniteDouble(style, "width", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
4804
+ val height = craftNativeFiniteDouble(style, "height", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
4194
4805
  control.layoutParams = LinearLayout.LayoutParams(
4195
4806
  width ?: ViewGroup.LayoutParams.WRAP_CONTENT,
4196
4807
  height ?: ViewGroup.LayoutParams.WRAP_CONTENT
4197
4808
  ).apply {
4198
- val margin = style.optDouble("margin", 0.0)
4199
- val horizontal = style.optDouble("marginHorizontal", margin)
4200
- val vertical = style.optDouble("marginVertical", margin)
4809
+ val margin = craftNativeFiniteDouble(style, "margin", 0.0)
4810
+ val horizontal = craftNativeFiniteDouble(style, "marginHorizontal", margin)
4811
+ val vertical = craftNativeFiniteDouble(style, "marginVertical", margin)
4201
4812
  setMargins(
4202
- dp(style.optDouble("marginLeft", horizontal)),
4203
- dp(style.optDouble("marginTop", vertical)),
4204
- dp(style.optDouble("marginRight", horizontal)),
4205
- dp(style.optDouble("marginBottom", vertical))
4813
+ dp(craftNativeFiniteDouble(style, "marginLeft", horizontal)),
4814
+ dp(craftNativeFiniteDouble(style, "marginTop", vertical)),
4815
+ dp(craftNativeFiniteDouble(style, "marginRight", horizontal)),
4816
+ dp(craftNativeFiniteDouble(style, "marginBottom", vertical))
4206
4817
  )
4207
4818
  }
4208
4819
  return control
@@ -4251,19 +4862,18 @@ class MainActivity : AppCompatActivity() {
4251
4862
  val direct = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
4252
4863
  .firstOrNull { it.isNotBlank() }
4253
4864
  if (direct != null) return direct
4254
- val children = node.optJSONArray("children") ?: return null
4255
- for (index in 0 until children.length()) {
4256
- val child = children.optJSONObject(index) ?: continue
4257
- val childProps = child.optJSONObject("props") ?: JSONObject()
4258
- authoredKey(child, childProps)?.let { return it }
4259
- }
4260
- return node.optString("id").takeIf { it.isNotBlank() }
4865
+ return null
4261
4866
  }
4262
4867
 
4263
4868
  private fun configureContainer(layout: CraftNativeFlexLayout, style: JSONObject) {
4264
4869
  layout.configure(style, resources.displayMetrics.density)
4265
4870
  }
4266
4871
 
4872
+ private fun craftNativeResizeMode(style: JSONObject, props: JSONObject): String {
4873
+ return craftNativeOptionalString(style.opt("resizeMode"))
4874
+ ?: craftNativeOptionalString(props.opt("resizeMode")).orEmpty()
4875
+ }
4876
+
4267
4877
  private fun flatListScrollEvent(list: CraftNativeFlatList): JSONObject = JSONObject()
4268
4878
  .put("contentOffset", JSONObject()
4269
4879
  .put("x", list.computeHorizontalScrollOffset())
@@ -4336,30 +4946,29 @@ class MainActivity : AppCompatActivity() {
4336
4946
  }
4337
4947
  val nodes = (0 until children.length()).mapNotNull { children.optJSONObject(it) }
4338
4948
  nodes.forEach { registerFlatListOwnership(screen, it, owner) }
4339
- list.onScrollEvent = events.optString("onScroll").takeIf { it.isNotBlank() }?.let { handler ->
4949
+ list.onScrollEvent = craftNativeEventName(events, "onScroll").takeIf { it.isNotBlank() }?.let { handler ->
4340
4950
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4341
4951
  }
4342
- list.onScrollBeginDrag = events.optString("onScrollBeginDrag").takeIf { it.isNotBlank() }?.let { handler ->
4952
+ list.onScrollBeginDrag = craftNativeEventName(events, "onScrollBeginDrag").takeIf { it.isNotBlank() }?.let { handler ->
4343
4953
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4344
4954
  }
4345
- list.onScrollEndDrag = events.optString("onScrollEndDrag").takeIf { it.isNotBlank() }?.let { handler ->
4955
+ list.onScrollEndDrag = craftNativeEventName(events, "onScrollEndDrag").takeIf { it.isNotBlank() }?.let { handler ->
4346
4956
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4347
4957
  }
4348
- list.onMomentumScrollBegin = events.optString("onMomentumScrollBegin").takeIf { it.isNotBlank() }?.let { handler ->
4958
+ list.onMomentumScrollBegin = craftNativeEventName(events, "onMomentumScrollBegin").takeIf { it.isNotBlank() }?.let { handler ->
4349
4959
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4350
4960
  }
4351
- list.onMomentumScrollEnd = events.optString("onMomentumScrollEnd").takeIf { it.isNotBlank() }?.let { handler ->
4961
+ list.onMomentumScrollEnd = craftNativeEventName(events, "onMomentumScrollEnd").takeIf { it.isNotBlank() }?.let { handler ->
4352
4962
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4353
4963
  }
4964
+ val threshold = craftNativeNonNegativeDouble(props, "threshold", 0.1)
4965
+ val authoredThreshold = craftNativeNonNegativeDouble(props, "onEndReachedThreshold", threshold)
4354
4966
  list.apply(
4355
4967
  nodes = nodes,
4356
4968
  horizontal = props.optBoolean("horizontal", false),
4357
4969
  columns = props.optInt("numColumns", 1),
4358
4970
  inverted = props.optBoolean("inverted", false),
4359
- endReachedThreshold = props.opt("onEndReachedThreshold")
4360
- ?.takeUnless { it == JSONObject.NULL }
4361
- ?.let { props.optDouble("onEndReachedThreshold", 0.1) }
4362
- ?: props.optDouble("threshold", 0.1),
4971
+ endReachedThreshold = authoredThreshold,
4363
4972
  renderItem = { node, itemIdentity, _ ->
4364
4973
  val rows = screen.flatListRows.getOrPut(list) { mutableMapOf() }
4365
4974
  val previous = rows[itemIdentity].orEmpty()
@@ -4373,7 +4982,7 @@ class MainActivity : AppCompatActivity() {
4373
4982
  val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
4374
4983
  releaseViewState(screen, controls.values.toList(), preservingInputDrafts = true)
4375
4984
  },
4376
- endReached = events.optString("onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
4985
+ endReached = craftNativeEventName(events, "onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
4377
4986
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
4378
4987
  },
4379
4988
  )
@@ -4393,20 +5002,21 @@ class MainActivity : AppCompatActivity() {
4393
5002
  private fun addJustificationSpacers(layout: CraftNativeFlexLayout, value: String) { }
4394
5003
 
4395
5004
  private fun applyViewStyle(view: View, style: JSONObject) {
4396
- view.alpha = style.optDouble("opacity", 1.0).toFloat()
5005
+ view.alpha = craftNativeFiniteFloat(style, "opacity", 1f)
4397
5006
  // A Text's padding (`px-4` on a line of text) is where its text
4398
5007
  // starts, as on iOS; containers and fields lay out their own.
4399
5008
  if (view is TextView && view !is EditText && view !is CraftNativeIconView) {
4400
5009
  val keys = listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom")
4401
5010
  if (keys.any(style::has)) {
4402
- val all = style.optDouble("padding", 0.0)
4403
- val horizontal = style.optDouble("paddingHorizontal", all)
4404
- val vertical = style.optDouble("paddingVertical", all)
5011
+ val density = resources.displayMetrics.density
5012
+ val all = craftNativePadding(style, "padding", 0, density)
5013
+ val horizontal = craftNativePadding(style, "paddingHorizontal", all, density)
5014
+ val vertical = craftNativePadding(style, "paddingVertical", all, density)
4405
5015
  view.setPadding(
4406
- dp(style.optDouble("paddingLeft", horizontal)),
4407
- dp(style.optDouble("paddingTop", vertical)),
4408
- dp(style.optDouble("paddingRight", horizontal)),
4409
- dp(style.optDouble("paddingBottom", vertical)),
5016
+ craftNativePadding(style, "paddingLeft", horizontal, density),
5017
+ craftNativePadding(style, "paddingTop", vertical, density),
5018
+ craftNativePadding(style, "paddingRight", horizontal, density),
5019
+ craftNativePadding(style, "paddingBottom", vertical, density),
4410
5020
  )
4411
5021
  } else if (view.javaClass == TextView::class.java) {
4412
5022
  view.setPadding(0, 0, 0, 0)
@@ -4416,18 +5026,18 @@ class MainActivity : AppCompatActivity() {
4416
5026
  if (view !is CraftNativeModalView) {
4417
5027
  view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
4418
5028
  }
4419
- view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
4420
- view.z = style.optDouble("zIndex", 0.0).toFloat()
5029
+ view.elevation = dp(craftNativeNonNegativeDouble(style, "elevation", 0.0)).toFloat()
5030
+ view.z = craftNativeFiniteFloat(style, "zIndex", 0f)
4421
5031
  val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
4422
- val border = style.optDouble("borderWidth", 0.0)
4423
- val radius = style.optDouble("borderRadius", 0.0)
5032
+ val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
5033
+ val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
4424
5034
  val sideBorderKeys = listOf("borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth")
4425
5035
  val hasSideBorders = (sideBorderKeys + listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")).any(style::has)
4426
- val sideWidths = sideBorderKeys.map { dp(style.optDouble(it, border)) }.toIntArray()
5036
+ val sideWidths = sideBorderKeys.map { dp(craftNativeNonNegativeDouble(style, it, border)) }.toIntArray()
4427
5037
  val cornerRadiusKeys = listOf("borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius")
4428
5038
  val hasCornerRadii = cornerRadiusKeys.any(style::has)
4429
5039
  val cornerRadii = cornerRadiusKeys.flatMap { value ->
4430
- val radius = dp(style.optDouble(value, radius)).toFloat()
5040
+ val radius = dp(craftNativeNonNegativeDouble(style, value, radius)).toFloat()
4431
5041
  listOf(radius, radius)
4432
5042
  }.toFloatArray()
4433
5043
  val sideColors = listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")
@@ -4470,19 +5080,20 @@ class MainActivity : AppCompatActivity() {
4470
5080
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
4471
5081
  (style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
4472
5082
  ) {
4473
- val shadowOpacity = style.optDouble("shadowOpacity", 1.0).coerceIn(0.0, 1.0)
5083
+ val shadowOpacity = craftNativeFiniteFloat(style, "shadowOpacity", 1f).coerceIn(0f, 1f)
4474
5084
  val shadow = color(style.optString("shadowColor"), Color.BLACK)
4475
5085
  val alphaShadow = Color.argb((Color.alpha(shadow) * shadowOpacity).roundToInt(), Color.red(shadow), Color.green(shadow), Color.blue(shadow))
4476
5086
  view.outlineAmbientShadowColor = alphaShadow
4477
5087
  view.outlineSpotShadowColor = alphaShadow
4478
- view.elevation = max(view.elevation, dp(style.optDouble("shadowRadius", 0.0) * 2.0).toFloat())
5088
+ val shadowRadius = craftNativeNonNegativeDouble(style, "shadowRadius", 0.0)
5089
+ view.elevation = max(view.elevation, dp(shadowRadius * 2.0).toFloat())
4479
5090
  val offset = style.optJSONObject("shadowOffset")
4480
- val offsetX = dp(offset?.optDouble("width", 0.0) ?: 0.0)
4481
- val offsetY = dp(offset?.optDouble("height", 0.0) ?: 0.0)
4482
- val shadowRadius = dp(style.optDouble("shadowRadius", 0.0)).toFloat()
5091
+ val offsetX = dp(craftNativeFiniteDouble(offset, "width", 0.0))
5092
+ val offsetY = dp(craftNativeFiniteDouble(offset, "height", 0.0))
5093
+ val radius = dp(shadowRadius).toFloat()
4483
5094
  view.outlineProvider = object : ViewOutlineProvider() {
4484
5095
  override fun getOutline(target: View, outline: Outline) {
4485
- outline.setRoundRect(offsetX, offsetY, target.width + offsetX, target.height + offsetY, shadowRadius)
5096
+ outline.setRoundRect(offsetX, offsetY, target.width + offsetX, target.height + offsetY, radius)
4486
5097
  }
4487
5098
  }
4488
5099
  view.invalidateOutline()
@@ -4515,11 +5126,11 @@ class MainActivity : AppCompatActivity() {
4515
5126
  val transform = transforms.optJSONObject(index) ?: continue
4516
5127
  val key = transform.keys().asSequence().firstOrNull() ?: continue
4517
5128
  when (key) {
4518
- "translateX" -> view.translationX += dp(transform.optDouble(key, 0.0)).toFloat()
4519
- "translateY" -> view.translationY += dp(transform.optDouble(key, 0.0)).toFloat()
4520
- "scale" -> transform.optDouble(key, 1.0).toFloat().let { view.scaleX *= it; view.scaleY *= it }
4521
- "scaleX" -> view.scaleX *= transform.optDouble(key, 1.0).toFloat()
4522
- "scaleY" -> view.scaleY *= transform.optDouble(key, 1.0).toFloat()
5129
+ "translateX" -> view.translationX += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
5130
+ "translateY" -> view.translationY += dp(craftNativeFiniteDouble(transform, key, 0.0)).toFloat()
5131
+ "scale" -> craftNativeFiniteDouble(transform, key, 1.0).toFloat().let { view.scaleX *= it; view.scaleY *= it }
5132
+ "scaleX" -> view.scaleX *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
5133
+ "scaleY" -> view.scaleY *= craftNativeFiniteDouble(transform, key, 1.0).toFloat()
4523
5134
  "rotate", "rotateZ" -> view.rotation += angleDegrees(transform.opt(key))
4524
5135
  "rotateX" -> view.rotationX += angleDegrees(transform.opt(key))
4525
5136
  "rotateY" -> view.rotationY += angleDegrees(transform.opt(key))
@@ -4531,15 +5142,16 @@ class MainActivity : AppCompatActivity() {
4531
5142
  }
4532
5143
 
4533
5144
  private fun skewFactor(value: Any?): Float =
4534
- kotlin.math.tan(angleDegrees(value).toDouble() * PI / 180.0).toFloat()
5145
+ kotlin.math.tan(angleDegrees(value).toDouble() * PI / 180.0).toFloat().takeIf { it.isFinite() } ?: 0f
4535
5146
 
4536
5147
  private fun angleDegrees(value: Any?): Float {
4537
5148
  val text = value?.toString()?.trim() ?: return 0f
4538
- return when {
5149
+ val result = when {
4539
5150
  text.endsWith("rad") -> Math.toDegrees(text.dropLast(3).toDoubleOrNull() ?: 0.0).toFloat()
4540
5151
  text.endsWith("deg") -> text.dropLast(3).toFloatOrNull() ?: 0f
4541
5152
  else -> text.toFloatOrNull() ?: 0f
4542
5153
  }
5154
+ return result.takeIf { it.isFinite() } ?: 0f
4543
5155
  }
4544
5156
 
4545
5157
  private fun ensureLayoutListener(screen: Screen, view: View) {
@@ -4562,15 +5174,22 @@ class MainActivity : AppCompatActivity() {
4562
5174
  }
4563
5175
 
4564
5176
  private fun configureIcon(view: CraftNativeIconView, props: JSONObject, style: JSONObject) {
4565
- val symbol = props.optString("symbol").ifBlank { props.optString("name") }
4566
- val size = style.optDouble("fontSize", 20.0).toFloat().coerceAtLeast(1f)
5177
+ val symbol = craftNativeOptionalString(props.opt("symbol"))
5178
+ ?: craftNativeOptionalString(props.opt("name")).orEmpty()
5179
+ val size = craftNativeFiniteFloat(style, "fontSize", 20f).coerceAtLeast(1f)
4567
5180
  view.setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
4568
- val foreground = style.optString("color").ifBlank { props.optString("color") }
5181
+ val foreground = craftNativeOptionalString(style.opt("color"))
5182
+ ?: craftNativeOptionalString(props.opt("color")).orEmpty()
4569
5183
  val tint = color(foreground, defaultTextColor)
4570
5184
  view.setTextColor(tint)
4571
5185
  // The app's own vector for it (craft-icons.json, the icon the web
4572
5186
  // shows), else the closest glyph Android's fonts have.
4573
- val vector = CraftNativeIcons.drawable(this, props.optString("name"), props.optString("symbol"), tint)
5187
+ val vector = CraftNativeIcons.drawable(
5188
+ this,
5189
+ craftNativeOptionalString(props.opt("name")).orEmpty(),
5190
+ craftNativeOptionalString(props.opt("symbol")).orEmpty(),
5191
+ tint,
5192
+ )
4574
5193
  view.vector = vector
4575
5194
  view.text = if (vector != null) "" else androidIconGlyph(symbol)
4576
5195
  val weight = when (style.optString("fontWeight")) {
@@ -4621,7 +5240,7 @@ class MainActivity : AppCompatActivity() {
4621
5240
 
4622
5241
  private fun configureTextStyle(view: TextView, style: JSONObject) {
4623
5242
  view.setTextColor(color(style.optString("color"), defaultTextColor))
4624
- view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 16.0).toFloat())
5243
+ view.setTextSize(TypedValue.COMPLEX_UNIT_SP, craftNativeFiniteFloat(style, "fontSize", 16f))
4625
5244
  val weight = when (style.optString("fontWeight")) {
4626
5245
  "bold" -> 700
4627
5246
  "100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
@@ -4648,11 +5267,11 @@ class MainActivity : AppCompatActivity() {
4648
5267
  }
4649
5268
  }
4650
5269
  view.letterSpacing = style.optDouble("letterSpacing", Double.NaN)
4651
- .takeUnless { it.isNaN() }
5270
+ .takeIf { it.isFinite() }
4652
5271
  ?.let { if (view.textSize > 0) dp(it).toFloat() / view.textSize else 0f }
4653
5272
  ?: 0f
4654
5273
  view.setLineSpacing(
4655
- style.optDouble("lineHeight", Double.NaN).takeUnless { it.isNaN() }?.let { dp(it) - view.textSize } ?: 0f,
5274
+ style.optDouble("lineHeight", Double.NaN).takeIf { it.isFinite() }?.let { dp(it) - view.textSize } ?: 0f,
4656
5275
  1f
4657
5276
  )
4658
5277
  val shadowColor = style.optString("textShadowColor").takeIf { it.isNotBlank() }
@@ -4661,9 +5280,9 @@ class MainActivity : AppCompatActivity() {
4661
5280
  } else {
4662
5281
  val offset = style.optJSONObject("textShadowOffset")
4663
5282
  view.setShadowLayer(
4664
- dp(style.optDouble("textShadowRadius", 0.0)).toFloat().coerceAtLeast(0f),
4665
- dp(offset?.optDouble("width", 0.0) ?: 0.0).toFloat(),
4666
- dp(offset?.optDouble("height", 0.0) ?: 0.0).toFloat(),
5283
+ dp(craftNativeFiniteDouble(style, "textShadowRadius", 0.0)).toFloat().coerceAtLeast(0f),
5284
+ dp(craftNativeFiniteDouble(offset, "width", 0.0)).toFloat(),
5285
+ dp(craftNativeFiniteDouble(offset, "height", 0.0)).toFloat(),
4667
5286
  color(shadowColor, Color.TRANSPARENT),
4668
5287
  )
4669
5288
  }
@@ -4727,7 +5346,7 @@ class MainActivity : AppCompatActivity() {
4727
5346
 
4728
5347
  private fun imageUri(source: Any?): String = when (source) {
4729
5348
  is String -> source
4730
- is JSONObject -> source.optString("uri")
5349
+ is JSONObject -> craftNativeOptionalString(source.opt("uri")).orEmpty()
4731
5350
  else -> ""
4732
5351
  }
4733
5352
 
@@ -4748,6 +5367,7 @@ class MainActivity : AppCompatActivity() {
4748
5367
  imageSources[view] = ""
4749
5368
  imageErrors[view] = "Image source is missing"
4750
5369
  view.setImageDrawable(null)
5370
+ imageDidChange(view)
4751
5371
  imageFailure(screen, view, uri, "Image source is missing")
4752
5372
  return
4753
5373
  }
@@ -4756,7 +5376,9 @@ class MainActivity : AppCompatActivity() {
4756
5376
  imageSources[view] = uri
4757
5377
  imageErrors.remove(view)
4758
5378
  view.setImageDrawable(null)
5379
+ imageDidChange(view)
4759
5380
  immediateImage(imageUri(defaultSource))?.let(view::setImageBitmap)
5381
+ if (view.drawable != null) imageDidChange(view)
4760
5382
  emitImageEvent(screen, view, screen.imageLoadStartHandlers[view])
4761
5383
 
4762
5384
  if (uri.startsWith("data:image/") && uri.contains(',')) {
@@ -4764,6 +5386,7 @@ class MainActivity : AppCompatActivity() {
4764
5386
  val bitmap = bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
4765
5387
  if (bitmap != null) {
4766
5388
  view.setImageBitmap(bitmap)
5389
+ imageDidChange(view)
4767
5390
  emitImageEvent(screen, view, screen.imageLoadHandlers[view])
4768
5391
  emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
4769
5392
  } else imageFailure(screen, view, uri, "Image data is invalid")
@@ -4773,6 +5396,7 @@ class MainActivity : AppCompatActivity() {
4773
5396
  val bitmap = runCatching { assets.open(uri).use { BitmapFactory.decodeStream(it) } }.getOrNull()
4774
5397
  if (bitmap != null) {
4775
5398
  view.setImageBitmap(bitmap)
5399
+ imageDidChange(view)
4776
5400
  emitImageEvent(screen, view, screen.imageLoadHandlers[view])
4777
5401
  emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
4778
5402
  } else imageFailure(screen, view, uri, "Bundled image was not found")
@@ -4795,6 +5419,7 @@ class MainActivity : AppCompatActivity() {
4795
5419
  imageJobs.remove(view)
4796
5420
  if (bitmap != null) {
4797
5421
  view.setImageBitmap(bitmap)
5422
+ imageDidChange(view)
4798
5423
  emitImageEvent(screen, view, screen.imageLoadHandlers[view])
4799
5424
  emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
4800
5425
  } else imageFailure(screen, view, uri, "Image download failed")
@@ -4805,6 +5430,7 @@ class MainActivity : AppCompatActivity() {
4805
5430
  private fun imageFailure(screen: Screen, view: ImageView, uri: String, message: String) {
4806
5431
  imageErrors[view] = message
4807
5432
  view.setImageDrawable(null)
5433
+ imageDidChange(view)
4808
5434
  val current = view.contentDescription?.toString().orEmpty()
4809
5435
  if (!current.contains(message)) {
4810
5436
  view.contentDescription = listOf(
@@ -4817,6 +5443,18 @@ class MainActivity : AppCompatActivity() {
4817
5443
  Log.e(TAG, "$message: $uri")
4818
5444
  }
4819
5445
 
5446
+ /**
5447
+ * An ImageView invalidates its own measure when its drawable changes, but
5448
+ * the native flex parent also memoizes intrinsic probes for the frame.
5449
+ * Clear that memo and walk through image-background wrappers so a source
5450
+ * swap can change an un-sized image's layout on the next pass.
5451
+ */
5452
+ private fun imageDidChange(view: ImageView) {
5453
+ view.invalidate()
5454
+ view.requestLayout()
5455
+ invalidateMeasurementAncestors(view.parent)
5456
+ }
5457
+
4820
5458
  private fun emitImageEvent(screen: Screen, view: ImageView, handler: String?, nativeEvent: JSONObject = JSONObject()) {
4821
5459
  if (handler.isNullOrBlank()) return
4822
5460
  send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", nativeEvent))
@@ -4837,17 +5475,17 @@ class MainActivity : AppCompatActivity() {
4837
5475
 
4838
5476
  private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
4839
5477
  view.tag = fallbackLabel
4840
- val label = props.optString("accessibilityLabel").takeIf { it.isNotBlank() }
4841
- ?: props.optString("alt").takeIf { (type == "Image" || type == "ImageBackground") && it.isNotBlank() }
4842
- val value = props.opt("accessibilityValue")?.takeUnless { it == JSONObject.NULL }?.toString()
5478
+ val label = craftNativeOptionalString(props.opt("accessibilityLabel"))
5479
+ ?: craftNativeOptionalString(props.opt("alt"))?.takeIf { type == "Image" || type == "ImageBackground" }
5480
+ val value = craftNativeOptionalString(props.opt("accessibilityValue"))
4843
5481
  view.contentDescription = when {
4844
5482
  label != null && value != null -> "$label, $value"
4845
5483
  label != null -> label
4846
5484
  value != null -> value
4847
5485
  else -> null
4848
5486
  }
4849
- view.tooltipText = props.optString("accessibilityHint").takeIf { it.isNotBlank() }
4850
- val role = props.optString("accessibilityRole").ifBlank {
5487
+ view.tooltipText = craftNativeOptionalString(props.opt("accessibilityHint"))
5488
+ val role = craftNativeOptionalString(props.opt("accessibilityRole")) ?: run {
4851
5489
  if (type == "Icon") "image" else type.lowercase()
4852
5490
  }
4853
5491
  val state = props.optJSONObject("accessibilityState")
@@ -4885,7 +5523,10 @@ class MainActivity : AppCompatActivity() {
4885
5523
  )
4886
5524
  }
4887
5525
 
4888
- private fun dp(value: Double) = (value * resources.displayMetrics.density).toInt()
5526
+ private fun dp(value: Double): Int {
5527
+ val pixels = value * resources.displayMetrics.density
5528
+ return if (pixels.isFinite()) pixels.toInt() else 0
5529
+ }
4889
5530
  private fun color(raw: String, fallback: Int) = runCatching { craftNativeColor(raw) }.getOrDefault(fallback)
4890
5531
 
4891
5532
  private fun inputTypeFor(props: JSONObject): Int {
@@ -5089,6 +5730,11 @@ class MainActivity : AppCompatActivity() {
5089
5730
  super.onStop()
5090
5731
  }
5091
5732
 
5733
+ override fun onLowMemory() {
5734
+ mapViews().forEach { it.lowMemory() }
5735
+ super.onLowMemory()
5736
+ }
5737
+
5092
5738
  override fun onNewIntent(intent: Intent) {
5093
5739
  super.onNewIntent(intent)
5094
5740
  setIntent(intent)
@@ -5187,10 +5833,40 @@ class MainActivity : AppCompatActivity() {
5187
5833
  screen.port.postMessage(Message.createStringMessage(message.toString()))
5188
5834
  } else {
5189
5835
  val script = "globalThis.__stxNativeCallback && globalThis.__stxNativeCallback(${JSONObject.quote(message.toString())})"
5190
- screen.isolate.evaluateJavaScriptAsync(script)
5836
+ reportJavaScriptFailure(screen, screen.isolate.evaluateJavaScriptAsync(script))
5191
5837
  }
5192
5838
  }
5193
5839
 
5840
+ /**
5841
+ * An error JavaScript threw and nothing caught, to the crash reporter. A
5842
+ * sandbox has no global error event: an uncaught error surfaces only as
5843
+ * the evaluation's future failing, so every evaluation that runs a
5844
+ * screen's code reports through here.
5845
+ */
5846
+ private fun reportJavaScriptFailure(screen: Screen, future: com.google.common.util.concurrent.ListenableFuture<String>) {
5847
+ if (!CraftCrashReporter.enabled) return
5848
+ future.addListener({
5849
+ try {
5850
+ future.get()
5851
+ } catch (error: Exception) {
5852
+ reportJavaScriptError(screen, error)
5853
+ }
5854
+ }, java.util.concurrent.Executor { command -> command.run() })
5855
+ }
5856
+
5857
+ private fun reportJavaScriptError(screen: Screen, error: Throwable) {
5858
+ val cause = (error as? java.util.concurrent.ExecutionException)?.cause ?: error
5859
+ if (cause is java.util.concurrent.CancellationException || cause is androidx.javascriptengine.IsolateTerminatedException) return
5860
+ val text = cause.message ?: cause.javaClass.simpleName
5861
+ // JavaScriptSandbox reports "Uncaught TypeError: message" and, when it
5862
+ // has one, the stack after a newline.
5863
+ val firstLine = text.lineSequence().firstOrNull()?.removePrefix("Uncaught ")?.trim() ?: text
5864
+ val name = Regex("^([A-Z][A-Za-z]*Error|Error):").find(firstLine)?.groupValues?.get(1)
5865
+ val message = if (name != null) firstLine.removePrefix("$name:").trim() else firstLine
5866
+ val stack = text.lineSequence().drop(1).joinToString("\n").ifBlank { null }
5867
+ CraftCrashReporter.captureJavaScript(name, message, stack, screen.route ?: "home", screen.headless)
5868
+ }
5869
+
5194
5870
  /** Back to the first screen in one move. */
5195
5871
  private fun popToRoot() {
5196
5872
  while (screens.size > 1) closeScreen(screens.removeAt(screens.lastIndex))
@@ -5229,6 +5905,11 @@ class MainActivity : AppCompatActivity() {
5229
5905
  screen.flatListRows.values.forEach { rows -> rows.values.forEach { addAll(it.values) } }
5230
5906
  }
5231
5907
 
5908
+ private fun mapViews(): Set<CraftNativeMapView> = allScreens()
5909
+ .flatMap { retainedViews(it) }
5910
+ .filterIsInstance<CraftNativeMapView>()
5911
+ .toSet()
5912
+
5232
5913
  private fun releaseFlatList(
5233
5914
  screen: Screen,
5234
5915
  list: CraftNativeFlatList,
@@ -5260,6 +5941,7 @@ class MainActivity : AppCompatActivity() {
5260
5941
  }
5261
5942
  released.filterIsInstance<CraftNativeModalView>().forEach { it.resetForTeardown() }
5262
5943
  released.filterIsInstance<CraftNativeVideoView>().forEach { it.stop() }
5944
+ released.filterIsInstance<CraftNativeMapView>().forEach { it.destroy() }
5263
5945
  released.filterIsInstance<CraftNativeFlexLayout>().forEach { it.onLayoutChanged = null }
5264
5946
  released.filterIsInstance<CraftNativeKeyboardAvoidingView>().forEach { it.content.onLayoutChanged = null }
5265
5947
  released.filterIsInstance<CraftNativeScrollView>().forEach { it.setRefreshHandler(null, false) }