@craft-native/android 0.0.174 → 0.0.176

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.
@@ -1,16 +1,20 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
3
  import android.animation.ValueAnimator
4
+ import android.Manifest
4
5
  import android.content.ClipData
5
6
  import android.content.ClipboardManager
6
7
  import android.content.Context
8
+ import android.content.pm.PackageManager
7
9
  import android.content.res.ColorStateList
8
10
  import android.content.res.Configuration
9
11
  import android.database.ContentObserver
10
12
  import android.content.Intent
11
13
  import android.media.AudioAttributes
12
14
  import android.media.AudioFormat
15
+ import android.media.MediaPlayer
13
16
  import android.media.AudioTrack
17
+ import android.graphics.Bitmap
14
18
  import android.graphics.BitmapFactory
15
19
  import android.graphics.Canvas
16
20
  import android.graphics.Color
@@ -52,6 +56,7 @@ import android.text.Layout
52
56
  import android.view.View
53
57
  import android.view.ViewGroup
54
58
  import android.view.ViewOutlineProvider
59
+ import android.view.ViewParent
55
60
  import android.view.ViewTreeObserver
56
61
  import android.widget.AdapterView
57
62
  import android.widget.ArrayAdapter
@@ -78,6 +83,7 @@ import androidx.core.content.ContextCompat
78
83
  import androidx.core.view.AccessibilityDelegateCompat
79
84
  import androidx.core.view.WindowInsetsCompat
80
85
  import androidx.core.view.ViewCompat
86
+ import androidx.core.view.WindowCompat
81
87
  import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
82
88
  import androidx.javascriptengine.JavaScriptIsolate
83
89
  import androidx.javascriptengine.JavaScriptConsoleCallback
@@ -111,26 +117,63 @@ import kotlin.math.max
111
117
  import kotlin.math.min
112
118
  import kotlin.math.PI
113
119
  import kotlin.math.ceil
120
+ import kotlin.math.abs
121
+ import kotlin.math.exp
114
122
  import kotlin.math.roundToInt
115
123
  import kotlin.math.roundToLong
116
124
  import kotlin.math.sin
125
+ import kotlin.math.pow
117
126
 
118
127
  private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
119
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
+
120
160
  private data class CraftNativeLength(
121
161
  val points: Int? = null,
122
162
  val fraction: Double? = null,
123
163
  ) {
124
- 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()) } }
125
165
 
126
166
  companion object {
127
167
  fun from(value: Any?, density: Float): CraftNativeLength? {
128
168
  if (value == null || value == JSONObject.NULL) return null
129
- 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
+ }
130
173
  val text = value.toString().trim()
131
- if (text.endsWith('%')) return text.dropLast(1).toDoubleOrNull()?.let { CraftNativeLength(fraction = it / 100) }
132
- if (text.endsWith("px")) return text.dropLast(2).toDoubleOrNull()?.let { CraftNativeLength(points = (it * density).roundToInt()) }
133
- 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()) }
134
177
  }
135
178
  }
136
179
  }
@@ -158,11 +201,13 @@ private data class CraftNativeLayoutStyle(
158
201
  fun length(name: String): CraftNativeLength? = CraftNativeLength.from(raw.opt(name), density)
159
202
  fun number(name: String): Int? {
160
203
  if (!raw.has(name) || raw.isNull(name)) return null
161
- 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
+ }
162
207
  }
163
208
  fun decimal(name: String): Double? {
164
209
  if (!raw.has(name) || raw.isNull(name)) return null
165
- return raw.optDouble(name, Double.NaN).takeUnless { it.isNaN() }
210
+ return raw.optDouble(name, Double.NaN).takeIf { it.isFinite() }
166
211
  }
167
212
  val flex = decimal("flex")
168
213
  return CraftNativeLayoutStyle(
@@ -275,6 +320,10 @@ private class CraftNativeSvgView(context: Context) : View(context) {
275
320
  val color: String = "#000000",
276
321
  val opacity: Float = 1f,
277
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,
278
327
  ) {
279
328
  fun merge(node: JSONObject): SvgPaint {
280
329
  val props = node.optJSONObject("props") ?: JSONObject()
@@ -284,17 +333,32 @@ private class CraftNativeSvgView(context: Context) : View(context) {
284
333
  props.has(name) && !props.isNull(name) -> props.optString(name)
285
334
  else -> null
286
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
+ }
287
347
  // Keep the cascade explicit: computed style wins over a raw prop
288
348
  // when both provide the same SVG paint value.
289
349
  val weight = value("fontWeight")
290
- val ownOpacity = value("opacity")?.toFloatOrNull() ?: 1f
350
+ val ownOpacity = value("opacity")?.toFloatOrNull()?.takeIf { it.isFinite() } ?: 1f
291
351
  return copy(
292
352
  fill = value("fill") ?: fill,
293
353
  stroke = value("stroke") ?: stroke,
294
- strokeWidth = value("strokeWidth")?.toFloatOrNull() ?: strokeWidth,
354
+ strokeWidth = value("strokeWidth")?.toFloatOrNull()?.takeIf { it.isFinite() } ?: strokeWidth,
295
355
  color = value("color") ?: color,
296
356
  opacity = ownOpacity * opacity,
297
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,
298
362
  )
299
363
  }
300
364
  }
@@ -352,7 +416,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
352
416
  val y = values.optDouble(1, Double.NaN).toFloat()
353
417
  val width = values.optDouble(2, Double.NaN).toFloat()
354
418
  val height = values.optDouble(3, Double.NaN).toFloat()
355
- 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
356
420
  return Quadruple(width, height, x, y)
357
421
  }
358
422
 
@@ -373,6 +437,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
373
437
  private fun drawPath(canvas: Canvas, node: JSONObject, style: SvgPaint) {
374
438
  val props = node.optJSONObject("props") ?: JSONObject()
375
439
  val geometry = props.optJSONArray("geometry") ?: return
440
+ if ((0 until geometry.length()).any { !geometry.optDouble(it, Double.NaN).toFloat().isFinite() }) return
376
441
  val path = Path()
377
442
  var index = 0
378
443
  while (index < geometry.length()) {
@@ -399,34 +464,29 @@ private class CraftNativeSvgView(context: Context) : View(context) {
399
464
  alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
400
465
  this.style = Paint.Style.STROKE
401
466
  strokeWidth = style.strokeWidth
402
- strokeCap = when (props.optString("strokeLinecap")) {
467
+ strokeCap = when (style.strokeLinecap) {
403
468
  "round" -> Paint.Cap.ROUND
404
469
  "square" -> Paint.Cap.SQUARE
405
470
  else -> Paint.Cap.BUTT
406
471
  }
407
- strokeJoin = when (props.optString("strokeLinejoin")) {
472
+ strokeJoin = when (style.strokeLinejoin) {
408
473
  "round" -> Paint.Join.ROUND
409
474
  "bevel" -> Paint.Join.BEVEL
410
475
  else -> Paint.Join.MITER
411
476
  }
412
- props.optJSONArray("strokeDasharray")?.let { values ->
413
- val dashes: List<Float> = (0 until values.length()).mapNotNull {
414
- values.optDouble(it, Double.NaN).takeUnless { value -> value.isNaN() }?.toFloat()
415
- }
416
- if (dashes.isNotEmpty()) {
417
- val dashPattern = dashes.toFloatArray()
418
- val pattern: FloatArray = if (dashPattern.size % 2 == 1) {
419
- FloatArray(dashPattern.size * 2) { index -> dashPattern[index % dashPattern.size] }
420
- } else {
421
- dashPattern
422
- }
423
- 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
424
483
  }
484
+ pathEffect = DashPathEffect(pattern, 0f)
425
485
  }
426
486
  }
427
487
  // `vector-effect="non-scaling-stroke"`: the stroke is in the view's
428
488
  // pixels, so a stretched chart's line keeps its width.
429
- if (props.optString("vectorEffect") == "non-scaling-stroke") {
489
+ if (style.vectorEffect == "non-scaling-stroke") {
430
490
  val line = Path(path).apply { transform(viewTransform) }
431
491
  canvas.save()
432
492
  canvas.concat(inverseTransform)
@@ -447,15 +507,17 @@ private class CraftNativeSvgView(context: Context) : View(context) {
447
507
  }
448
508
  if (text.isEmpty()) return
449
509
  val size = (if (props.has("fontSize")) props.optDouble("fontSize", 12.0) else nodeStyle.optDouble("fontSize", 12.0))
450
- .toFloat()
510
+ .takeIf { it.isFinite() }
511
+ ?.toFloat()
512
+ ?: 12f
451
513
  val paint = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
452
514
  color = resolveColor(style.fill, style.color, Color.BLACK)
453
515
  alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
454
516
  textSize = size
455
517
  typeface = style.typeface
456
518
  }
457
- var x = props.optDouble("x", 0.0).toFloat()
458
- 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
459
521
  when (props.optString("textAnchor")) {
460
522
  "middle" -> x -= paint.measureText(text) / 2f
461
523
  "end" -> x -= paint.measureText(text)
@@ -480,6 +542,8 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
480
542
  private val polylines = mutableMapOf<String, Polyline>()
481
543
  private val markerHandlers = mutableMapOf<String, String>()
482
544
  private var onMarkerTap: ((String) -> Unit)? = null
545
+ private var lifecycleStarted = false
546
+ private var destroyed = false
483
547
 
484
548
  init {
485
549
  addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
@@ -488,11 +552,15 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
488
552
  }
489
553
 
490
554
  override fun onMapReady(map: GoogleMap) {
555
+ if (destroyed) return
491
556
  googleMap = map
492
557
  map.setOnMarkerClickListener { marker ->
493
558
  val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
494
559
  onMarkerTap?.invoke(id)
495
- 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
496
564
  }
497
565
  applyPending()
498
566
  }
@@ -511,32 +579,49 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
511
579
  map.uiSettings.isScrollGesturesEnabled = pendingProps.optBoolean("scrollEnabled", true)
512
580
  map.uiSettings.isZoomGesturesEnabled = pendingProps.optBoolean("zoomEnabled", true)
513
581
  map.uiSettings.isRotateGesturesEnabled = pendingProps.optBoolean("rotateEnabled", false)
514
- map.mapType = when (pendingProps.optString("mapType").lowercase()) {
582
+ map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) {
515
583
  "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
516
584
  "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
517
585
  "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
518
586
  else -> GoogleMap.MAP_TYPE_NORMAL
519
587
  }
588
+ val showUserLocation = pendingProps.optBoolean("showsUserLocation", false) && hasLocationPermission()
589
+ runCatching { map.isMyLocationEnabled = showUserLocation }
520
590
  val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
521
591
  val nextHandlers = mutableMapOf<String, String>()
522
- val nextPolylines = mutableMapOf<String, Pair<List<LatLng>, Int>>()
592
+ val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
523
593
  for (index in 0 until pendingChildren.length()) {
524
594
  val child = pendingChildren.optJSONObject(index) ?: continue
525
595
  val type = child.optString("type").lowercase()
526
596
  val props = child.optJSONObject("props") ?: JSONObject()
527
597
  val events = child.optJSONObject("events") ?: JSONObject()
528
- val id = props.opt("id")?.takeUnless { it == JSONObject.NULL }?.toString()
529
- ?: child.optString("id").takeIf { it.isNotBlank() }
598
+ val id = craftNativeOptionalString(props.opt("id"))
599
+ ?: craftNativeOptionalString(child.opt("id"))
530
600
  ?: "overlay-$index"
531
601
  val points = coordinates(props.optJSONArray("geometry"))
532
602
  if (type == "mapmarker" || type == "marker") {
533
603
  points.firstOrNull()?.let { point ->
534
- nextMarkers[id] = point to props.optString("title").takeIf { it.isNotBlank() }
535
- events.optString("onPress").ifBlank { events.optString("onMarkerPress") }
536
- .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 }
537
608
  }
538
609
  } else if (type == "mappolyline" || type == "polyline") {
539
- if (points.size > 1) nextPolylines[id] = points to color(props.optString("strokeColor"))
610
+ if (points.size > 1) {
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)
624
+ }
540
625
  }
541
626
  }
542
627
  markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
@@ -551,10 +636,18 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
551
636
  }
552
637
  markerHandlers.clear()
553
638
  markerHandlers.putAll(nextHandlers)
554
- polylines.values.forEach { it.remove() }
555
- polylines.clear()
639
+ polylines.keys.filter { nextPolylines[it] == null }.forEach { id ->
640
+ polylines.remove(id)?.remove()
641
+ }
556
642
  for ((id, value) in nextPolylines) {
557
- polylines[id] = map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(3f))
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
+ }
558
651
  }
559
652
  if (pendingProps.optBoolean("fitToBounds", false)) fitToBounds()
560
653
  region(pendingProps.optJSONObject("region") ?: pendingProps.optJSONObject("initialRegion"))?.let { bounds ->
@@ -578,20 +671,56 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
578
671
 
579
672
  override fun onAttachedToWindow() {
580
673
  super.onAttachedToWindow()
674
+ if (destroyed) return
675
+ mapView.onStart()
581
676
  mapView.onResume()
677
+ lifecycleStarted = true
582
678
  }
583
679
 
584
680
  override fun onDetachedFromWindow() {
585
- mapView.onPause()
681
+ if (lifecycleStarted) {
682
+ mapView.onPause()
683
+ mapView.onStop()
684
+ lifecycleStarted = false
685
+ }
586
686
  super.onDetachedFromWindow()
587
687
  }
588
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
+
711
+ private fun hasLocationPermission(): Boolean =
712
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
713
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
714
+
589
715
  private fun coordinates(geometry: JSONArray?): List<LatLng> {
590
716
  if (geometry == null) return emptyList()
591
717
  return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
592
718
  val latitude = geometry.optDouble(index, Double.NaN)
593
719
  val longitude = geometry.optDouble(index + 1, Double.NaN)
594
- 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
595
724
  }
596
725
  }
597
726
 
@@ -602,7 +731,12 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
602
731
  val longitude = center.optDouble("longitude", Double.NaN)
603
732
  val latitudeDelta = span.optDouble("latitudeDelta", span.optDouble("latitude_delta", Double.NaN))
604
733
  val longitudeDelta = span.optDouble("longitudeDelta", span.optDouble("longitude_delta", Double.NaN))
605
- 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
606
740
  return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
607
741
  }
608
742
 
@@ -648,6 +782,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
648
782
  (0 until raw.length()).mapNotNull { raw.optString(it).takeIf { edge -> edge in setOf("top", "right", "bottom", "left") } }.toSet()
649
783
  }
650
784
  applySafeAreaPadding()
785
+ invalidateMeasurements()
651
786
  }
652
787
 
653
788
  fun setWindowSafeInsets(left: Int, top: Int, right: Int, bottom: Int) {
@@ -659,6 +794,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
659
794
  if ("bottom" in safeAreaEdges) insets[3] else 0,
660
795
  )
661
796
  applySafeAreaPadding()
797
+ invalidateMeasurements()
662
798
  }
663
799
 
664
800
  private fun applySafeAreaPadding() {
@@ -707,6 +843,31 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
707
843
  private fun probedWidth(size: Long): Int = (size shr 32).toInt()
708
844
  private fun probedHeight(size: Long): Int = size.toInt()
709
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
+
710
871
  /**
711
872
  * Lays a child out at its final frame, measuring it again at exactly
712
873
  * that size first when the measure pass left it another size (a
@@ -744,34 +905,50 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
744
905
  alignItems = style.optString("alignItems", "stretch")
745
906
  alignContent = style.optString("alignContent", "flex-start")
746
907
  justifyContent = style.optString("justifyContent", "flex-start")
747
- val gap = style.optDouble("gap", 0.0) * density
748
- rowGap = style.optDouble("rowGap", gap / density.toDouble()).toFloat() * density
749
- 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)
750
917
  grid = style.optString("display") == "grid"
751
918
  gridColumns = craftNativeGridColumnCount(
752
919
  if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
753
920
  )
754
921
  gridAutoRows = CraftNativeLength.from(style.opt("gridAutoRows"), density)
755
- val padding = (style.optDouble("padding", 0.0) * density).toInt()
756
- val horizontal = (style.optDouble("paddingHorizontal", padding.toDouble() / density.toDouble()) * density).toInt()
757
- 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)
758
925
  setPadding(
759
- (style.optDouble("paddingLeft", horizontal.toDouble() / density.toDouble()) * density).toInt(),
760
- (style.optDouble("paddingTop", vertical.toDouble() / density.toDouble()) * density).toInt(),
761
- (style.optDouble("paddingRight", horizontal.toDouble() / density.toDouble()) * density).toInt(),
762
- (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),
763
930
  )
764
- requestLayout()
931
+ invalidateMeasurements()
765
932
  }
766
933
 
767
934
  fun setChildStyle(view: View, style: JSONObject, density: Float) {
768
935
  styles[view] = CraftNativeLayoutStyle.from(style, density)
769
- requestLayout()
936
+ invalidateMeasurements()
937
+ invalidateMeasurementAncestors(parent)
770
938
  }
771
939
 
772
940
  override fun onViewRemoved(child: View) {
941
+ val parentBeforeRemoval = parent
773
942
  styles.remove(child)
774
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)
775
952
  }
776
953
 
777
954
  private data class Entry(val view: View, val style: CraftNativeLayoutStyle, val width: Int, val height: Int)
@@ -1025,6 +1202,116 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1025
1202
  else -> 0.0
1026
1203
  }
1027
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
+
1028
1315
  private fun justifyCode(): Int = when (justifyContent) {
1029
1316
  "center" -> 1
1030
1317
  "flex-end" -> 2
@@ -1152,11 +1439,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1152
1439
  contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap, rowGap,
1153
1440
  flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
1154
1441
  flow.map { it.style.width?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
1155
- flow.map { it.style.height?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
1442
+ flow.map { it.style.height?.resolve(contentHeight)?.toFloat() ?: Float.NaN }.toFloatArray(),
1156
1443
  flow.map { it.style.minWidth?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
1157
1444
  flow.map { it.style.maxWidth?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
1158
- flow.map { it.style.minHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
1159
- 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(),
1160
1447
  flow.flatMap {
1161
1448
  val lp = it.view.layoutParams as? MarginLayoutParams
1162
1449
  listOf((lp?.leftMargin ?: 0).toFloat(), (lp?.rightMargin ?: 0).toFloat(), (lp?.topMargin ?: 0).toFloat(), (lp?.bottomMargin ?: 0).toFloat())
@@ -1242,6 +1529,22 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1242
1529
  val contentWidth = width - paddingLeft - paddingRight
1243
1530
  val contentHeight = height - paddingTop - paddingBottom
1244
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
+ }
1245
1548
  val gap = if (orientation == HORIZONTAL) columnGap else rowGap
1246
1549
  val crossGap = if (orientation == HORIZONTAL) rowGap else columnGap
1247
1550
  val availableMain = if (orientation == HORIZONTAL) contentWidth else contentHeight
@@ -1436,7 +1739,9 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
1436
1739
 
1437
1740
  fun configure(props: JSONObject, style: JSONObject, density: Float) {
1438
1741
  behavior = props.optString("behavior", "padding").takeIf { it in setOf("height", "position", "padding") } ?: "padding"
1439
- 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()
1440
1745
  content.configure(style, density)
1441
1746
  basePadding = intArrayOf(content.paddingLeft, content.paddingTop, content.paddingRight, content.paddingBottom)
1442
1747
  applyKeyboardInset()
@@ -1538,10 +1843,10 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1538
1843
  }
1539
1844
 
1540
1845
  fun setRefreshAppearance(props: JSONObject, density: Float) {
1541
- val tint = props.optString("tintColor").takeIf { it.isNotBlank() }
1846
+ val tint = craftNativeOptionalString(props.opt("tintColor"))
1542
1847
  if (tint != null) setColorSchemeColors(craftNativeColor(tint))
1543
1848
  else setColorSchemeColors(Color.WHITE)
1544
- val background = props.optString("progressBackgroundColor").takeIf { it.isNotBlank() }
1849
+ val background = craftNativeOptionalString(props.opt("progressBackgroundColor"))
1545
1850
  if (background != null) setProgressBackgroundColorSchemeColor(craftNativeColor(background))
1546
1851
  else setProgressBackgroundColorSchemeColor(Color.TRANSPARENT)
1547
1852
  val offset = props.optDouble("progressViewOffset", Double.NaN)
@@ -1902,6 +2207,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1902
2207
  private var pickerMode: PickerMode? = null
1903
2208
  private var datePicker: DatePicker? = null
1904
2209
  private var timePicker: TimePicker? = null
2210
+ private var defaultDateBounds: Pair<Long, Long>? = null
1905
2211
  private var minuteInterval = 1
1906
2212
  private var countdownSecond = 0
1907
2213
  private var suppressChanges = true
@@ -1929,6 +2235,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1929
2235
  }
1930
2236
  if (requestedMode == PickerMode.DATE || requestedMode == PickerMode.DATE_TIME) {
1931
2237
  datePicker = DatePicker(context).also { picker ->
2238
+ defaultDateBounds = picker.minDate to picker.maxDate
1932
2239
  val today = Calendar.getInstance()
1933
2240
  picker.init(today.get(Calendar.YEAR), today.get(Calendar.MONTH), today.get(Calendar.DAY_OF_MONTH)) { _, _, _, _ -> changed() }
1934
2241
  content.addView(picker, LinearLayout.LayoutParams(
@@ -1952,8 +2259,9 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1952
2259
 
1953
2260
  suppressChanges = true
1954
2261
  minuteInterval = props.optInt("minuteInterval", 1).takeIf { it in 1..30 && 60 % it == 0 } ?: 1
1955
- pickerDate(props.opt("minimumDate"))?.let { datePicker?.minDate = it.timeInMillis }
1956
- 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
1957
2265
  if (requestedMode == PickerMode.COUNTDOWN) {
1958
2266
  countdownSecond = 0
1959
2267
  countdownSeconds(props.opt("value"))?.let { applyCountdown(it) }
@@ -2038,7 +2346,11 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2038
2346
  }
2039
2347
 
2040
2348
  private fun pickerDate(value: Any?): Calendar? {
2041
- 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
+ }
2042
2354
  val text = value?.toString()?.trim().orEmpty()
2043
2355
  if (text.isBlank() || text == JSONObject.NULL.toString()) return null
2044
2356
  val formats = listOf(
@@ -2056,7 +2368,11 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2056
2368
  }
2057
2369
 
2058
2370
  private fun countdownSeconds(value: Any?): Int? {
2059
- 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
+ }
2060
2376
  val text = value?.toString()?.trim().orEmpty()
2061
2377
  if (text.isBlank() || text == JSONObject.NULL.toString()) return null
2062
2378
  text.toDoubleOrNull()?.takeIf { it.isFinite() }?.let { return it.coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt() }
@@ -2065,7 +2381,8 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2065
2381
  val hours = parts[0].toDoubleOrNull() ?: return null
2066
2382
  val minutes = parts[1].toDoubleOrNull() ?: return null
2067
2383
  val seconds = if (parts.size == 3) parts[2].toDoubleOrNull() ?: return null else 0.0
2068
- 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
2069
2386
  return (hours * 3600 + minutes * 60 + seconds).coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
2070
2387
  }
2071
2388
  }
@@ -2075,26 +2392,33 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2075
2392
  private var shouldLoop = false
2076
2393
  private var shouldPlay = false
2077
2394
  private var muted = false
2395
+ private var resizeMode = "contain"
2396
+ private var preparedPlayer: MediaPlayer? = null
2078
2397
 
2079
- 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?) {
2080
2399
  val uri = when (source) {
2081
2400
  is String -> source
2082
- is JSONObject -> source.optString("uri")
2401
+ is JSONObject -> craftNativeOptionalString(source.opt("uri")).orEmpty()
2083
2402
  else -> ""
2084
2403
  }
2085
2404
  shouldLoop = loop
2086
2405
  shouldPlay = autoplay && !paused
2087
2406
  this.muted = muted
2407
+ this.resizeMode = resizeMode?.lowercase()?.takeIf { it in setOf("cover", "contain", "stretch", "center") } ?: "contain"
2408
+ applyScalingMode()
2088
2409
  setMediaController(if (controls) MediaController(context).also { it.setAnchorView(this) } else null)
2089
2410
  if (uri != sourceUri) {
2090
2411
  sourceUri = uri
2091
2412
  stopPlayback()
2413
+ preparedPlayer = null
2092
2414
  if (uri.isBlank()) return
2093
2415
  // A copy kept on the phone (craft.media.keep) plays in place of
2094
2416
  // the address: offline in a gym with no signal.
2095
2417
  val kept = CraftNativeMedia.local(context, uri)
2096
2418
  setVideoURI(if (kept != null) Uri.fromFile(kept) else Uri.parse(uri))
2097
2419
  setOnPreparedListener { mediaPlayer ->
2420
+ preparedPlayer = mediaPlayer
2421
+ applyScalingMode()
2098
2422
  mediaPlayer.isLooping = shouldLoop
2099
2423
  val volume = if (this.muted) 0f else 1f
2100
2424
  mediaPlayer.setVolume(volume, volume)
@@ -2112,12 +2436,27 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2112
2436
  fun stop() {
2113
2437
  stopPlayback()
2114
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
+ )
2115
2447
  }
2116
2448
  }
2117
2449
 
2118
- /** 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
+ */
2119
2458
  private class CraftNativeAudio {
2120
- 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)
2121
2460
 
2122
2461
  private class Parameter(initial: Double) {
2123
2462
  private var current = initial
@@ -2128,8 +2467,8 @@ private class CraftNativeAudio {
2128
2467
  events.clear()
2129
2468
  }
2130
2469
 
2131
- @Synchronized fun schedule(value: Double, time: Double, ramp: Boolean) {
2132
- 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)
2133
2472
  events.sortBy { it.time }
2134
2473
  }
2135
2474
 
@@ -2138,9 +2477,20 @@ private class CraftNativeAudio {
2138
2477
  var previousValue = current
2139
2478
  for (event in events) {
2140
2479
  if (time < event.time) {
2141
- if (!event.ramp || event.time <= previousTime) return previousValue
2480
+ if (event.time <= previousTime) return previousValue
2142
2481
  val fraction = (time - previousTime) / (event.time - previousTime)
2143
- 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
+ }
2144
2494
  }
2145
2495
  previousTime = event.time
2146
2496
  previousValue = event.value
@@ -2149,73 +2499,182 @@ private class CraftNativeAudio {
2149
2499
  }
2150
2500
  }
2151
2501
 
2152
- private class OscillatorState {
2502
+ private class NodeState(
2503
+ val contextId: String,
2504
+ val id: String,
2505
+ val kind: String,
2506
+ ) {
2153
2507
  val frequency = Parameter(440.0)
2154
- @Volatile var gain = Parameter(1.0)
2155
- @Volatile var phase = 0.0
2156
- @Volatile var active = false
2157
- var contextStart = 0.0
2158
- }
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
+ )
2159
2526
 
2160
- private val handler = Handler(Looper.getMainLooper())
2161
- private val oscillators = mutableMapOf<String, OscillatorState>()
2162
- 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>()
2163
2530
  private val contextStarts = mutableMapOf<String, Double>()
2531
+ private val suspended = mutableSetOf<String>()
2164
2532
  private var track: AudioTrack? = null
2165
2533
  @Volatile private var running = false
2166
2534
  private var renderThread: Thread? = null
2167
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
+
2168
2540
  @Synchronized fun handle(payload: JSONObject) {
2541
+ val contextId = payload.optString("contextId")
2169
2542
  when (payload.optString("action")) {
2170
2543
  "createContext" -> {
2171
- payload.optString("contextId").takeIf { it.isNotBlank() }?.let {
2544
+ contextId.takeIf { it.isNotBlank() }?.let {
2172
2545
  contextStarts[it] = System.nanoTime() / 1_000_000_000.0
2546
+ suspended.remove(it)
2173
2547
  }
2174
2548
  }
2175
- "createOscillator" -> {
2549
+ "createOscillator", "createGain", "createBufferSource", "createBiquadFilter",
2550
+ "createDelay", "createDynamicsCompressor", "createConvolver" -> {
2176
2551
  val id = payload.optString("nodeId")
2177
- if (id.isNotBlank()) oscillators.putIfAbsent(id, OscillatorState()).also {
2178
- oscillators[id]?.contextStart = contextStarts[payload.optString("contextId")]
2179
- ?: (System.nanoTime() / 1_000_000_000.0)
2180
- 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
+ )
2181
2568
  }
2182
2569
  }
2183
- "createGain" -> {
2184
- val id = payload.optString("nodeId")
2185
- 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)
2186
2582
  }
2187
2583
  "connect" -> {
2188
2584
  val source = payload.optString("sourceId")
2189
- 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
+ }
2190
2589
  }
2191
- "frequency" -> oscillators[payload.optString("nodeId")]?.frequency?.set(payload.optDouble("value", 0.0).coerceAtLeast(0.0))
2192
- "frequencyAtTime", "frequencyRampToTime" -> oscillators[payload.optString("nodeId")]?.frequency?.schedule(
2193
- payload.optDouble("value", 0.0).coerceAtLeast(0.0),
2194
- payload.optDouble("time", 0.0),
2195
- payload.optString("action") == "frequencyRampToTime",
2196
- )
2197
- "gain" -> gains[payload.optString("nodeId")]?.set(payload.optDouble("value", 0.0).coerceAtLeast(0.0))
2198
- "gainAtTime", "gainRampToTime" -> gains[payload.optString("nodeId")]?.schedule(
2199
- payload.optDouble("value", 0.0).coerceAtLeast(0.0),
2200
- payload.optDouble("time", 0.0),
2201
- payload.optString("action") == "gainRampToTime",
2202
- )
2203
- "start", "stop" -> {
2204
- 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
2205
2631
  ensureTrack()
2206
- val active = payload.optString("action") == "start"
2207
- val delay = (payload.optDouble("delay", 0.0).coerceAtLeast(0.0) * 1000).roundToLong()
2208
- 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)
2209
2646
  }
2210
2647
  "close" -> close()
2211
2648
  }
2212
2649
  }
2213
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
+
2214
2672
  @Synchronized fun close() {
2215
- oscillators.values.forEach { it.active = false }
2216
- oscillators.clear()
2217
- gains.clear()
2673
+ nodes.clear()
2674
+ inputs.clear()
2675
+ buffers.clear()
2218
2676
  contextStarts.clear()
2677
+ suspended.clear()
2219
2678
  running = false
2220
2679
  renderThread?.interrupt()
2221
2680
  renderThread = null
@@ -2256,31 +2715,66 @@ private class CraftNativeAudio {
2256
2715
 
2257
2716
  private fun render(output: AudioTrack, sampleRate: Int) {
2258
2717
  val buffer = ShortArray(1024 * 2)
2718
+ val stereo = DoubleArray(2)
2259
2719
  while (running && !Thread.currentThread().isInterrupted) {
2260
- val snapshot = synchronized(this) {
2261
- oscillators.mapValues { (id, state) ->
2262
- Triple(state, state.gain, state.active)
2263
- }
2264
- }
2265
- val now = System.nanoTime() / 1_000_000_000.0
2266
2720
  for (frame in 0 until 1024) {
2267
- var sample = 0.0
2268
- snapshot.forEach { (_, value) ->
2269
- val (state, gain, active) = value
2270
- if (!active) return@forEach
2271
- val time = (now - state.contextStart).coerceAtLeast(0.0) + frame.toDouble() / sampleRate
2272
- val frequency = state.frequency.valueAt(time)
2273
- sample += sin(state.phase) * gain.valueAt(time)
2274
- state.phase += 2 * PI * frequency / sampleRate
2275
- 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
+ }
2276
2730
  }
2277
- val value = (sample.coerceIn(-1.0, 1.0) * Short.MAX_VALUE).toInt().toShort()
2278
- buffer[frame * 2] = value
2279
- 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()
2280
2733
  }
2281
2734
  if (output.write(buffer, 0, buffer.size, AudioTrack.WRITE_BLOCKING) < 0) break
2282
2735
  }
2283
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
+ }
2284
2778
  }
2285
2779
 
2286
2780
  private class CraftTextDecorationSpan(
@@ -2563,6 +3057,16 @@ class MainActivity : AppCompatActivity() {
2563
3057
  }.getOrDefault(JSONObject())
2564
3058
  hapticsEnabled = config.optBoolean("enableHaptics", false)
2565
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
+ }
2566
3070
  capabilities = CraftNativeCapabilities(this, config)
2567
3071
  routes = CraftNativeRoutes(config)
2568
3072
  stores = CraftNativeStores.get(this, config)
@@ -2891,18 +3395,36 @@ class MainActivity : AppCompatActivity() {
2891
3395
  audioMessage({ action: action, contextId: contextId, nodeId: nodeId, value: current });
2892
3396
  }
2893
3397
  });
2894
- function schedule(value, when, ramp) {
3398
+ param._paramOwner = nodeId;
3399
+ param._paramName = action;
3400
+ function schedule(value, when, suffix) {
2895
3401
  current = Number(value) || 0;
2896
3402
  var time = Number(when);
2897
- 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 });
2898
3404
  }
2899
- param.setValueAtTime = function(value, when) { schedule(value, when, false); return param; };
2900
- 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; };
2901
3409
  return param;
2902
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
+ }
2903
3424
  function AudioContext() {
2904
3425
  this._id = 'audio-' + (++nextAudioId);
2905
3426
  this._started = Date.now() / 1000;
3427
+ this.sampleRate = 44100;
2906
3428
  this.state = 'running';
2907
3429
  this.destination = { _contextId: this._id, _nodeId: 'destination' };
2908
3430
  audioMessage({ action: 'createContext', contextId: this._id });
@@ -2912,37 +3434,101 @@ class MainActivity : AppCompatActivity() {
2912
3434
  });
2913
3435
  AudioContext.prototype.createOscillator = function() {
2914
3436
  var context = this;
2915
- var node = { _contextId: context._id, _nodeId: context._id + '-osc-' + (++nextAudioId) };
3437
+ var node = makeNode(context, 'Oscillator');
2916
3438
  node.frequency = audioParam(context._id, node._nodeId, 'frequency', 440);
2917
- node.connect = function(destination) {
2918
- audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2919
- return destination;
2920
- };
2921
3439
  node.start = function(when) {
2922
3440
  audioMessage({ action: 'start', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2923
3441
  };
2924
3442
  node.stop = function(when) {
2925
3443
  audioMessage({ action: 'stop', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2926
3444
  };
2927
- 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 });
2928
3446
  return node;
2929
3447
  };
2930
3448
  AudioContext.prototype.createGain = function() {
2931
3449
  var context = this;
2932
- var node = { _contextId: context._id, _nodeId: context._id + '-gain-' + (++nextAudioId) };
3450
+ var node = makeNode(context, 'Gain');
2933
3451
  node.gain = audioParam(context._id, node._nodeId, 'gain', 1);
2934
- node.connect = function(destination) {
2935
- audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2936
- return destination;
2937
- };
2938
- 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 });
2939
3453
  return node;
2940
3454
  };
2941
- AudioContext.prototype.resume = function() { this.state = 'running'; return Promise.resolve(); };
2942
- AudioContext.prototype.suspend = function() { this.state = 'suspended'; return Promise.resolve(); };
2943
- 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(); };
2944
3528
  globalThis.AudioContext = AudioContext;
2945
3529
  globalThis.webkitAudioContext = AudioContext;
3530
+ globalThis.BaseAudioContext = AudioContext;
3531
+ globalThis.AudioBuffer = function() {};
2946
3532
  }
2947
3533
  })()
2948
3534
  """.trimIndent()
@@ -3016,6 +3602,7 @@ class MainActivity : AppCompatActivity() {
3016
3602
  }
3017
3603
  } catch (error: Exception) {
3018
3604
  Log.e(TAG, "Native STX bundle failed", error)
3605
+ reportJavaScriptError(activeScreen, error)
3019
3606
  if (activeScreen.active) showError("Native STX bundle failed: ${error.message}")
3020
3607
  }
3021
3608
  }, executor)
@@ -3033,6 +3620,8 @@ class MainActivity : AppCompatActivity() {
3033
3620
  * screen shown before hears true (its `navigation.onAppear`).
3034
3621
  */
3035
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")
3036
3625
  val bars = chrome
3037
3626
  val host: ViewGroup = bars?.content ?: root
3038
3627
  host.removeAllViews()
@@ -3245,8 +3834,13 @@ class MainActivity : AppCompatActivity() {
3245
3834
  }
3246
3835
  "NAVIGATE_OPEN" -> payload.optString("path").takeIf { it.isNotBlank() }?.let(::navigateOpen)
3247
3836
  "NAVIGATION_SET_OPTIONS" -> {
3248
- payload.keys().forEach { key -> screen.options.put(key, payload.opt(key)) }
3249
- 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
+ }
3250
3844
  }
3251
3845
  "SYNC_WRITE" -> syncWrite(screen, payload)
3252
3846
  "TIMER" -> {
@@ -3262,15 +3856,20 @@ class MainActivity : AppCompatActivity() {
3262
3856
  "TIMER_CLEAR" -> screen.timers.remove(payload.optInt("id"))?.let(capabilityHandler::removeCallbacks)
3263
3857
  "FRAME" -> requestFrame(screen)
3264
3858
  "NAVIGATE", "NAVIGATE_REPLACE" -> {
3265
- 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
3266
3865
  val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
3267
3866
  if (message.optString("type") == "NAVIGATE_REPLACE") {
3268
3867
  closeScreen(screens.removeAt(screens.lastIndex))
3269
3868
  }
3270
3869
  openScreen(route, payload.optJSONObject("params") ?: JSONObject())
3271
3870
  }
3272
- "NAVIGATE_BACK" -> if (screens.lastOrNull() === screen || screen === appScreen) back()
3273
- "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()
3274
3873
  "API_REQUEST" -> answerAPI(screen, message, payload)
3275
3874
  "API_CANCEL" -> {
3276
3875
  val id = payload.optString("requestId")
@@ -3311,7 +3910,7 @@ class MainActivity : AppCompatActivity() {
3311
3910
  private fun applyTargetedUpdates(screen: Screen, ids: List<String>): Boolean {
3312
3911
  val applied = mutableSetOf<String>()
3313
3912
  for (id in ids) {
3314
- val target = screen.flatListOwners[id] ?: id
3913
+ val target = drawnTarget(screen, id) ?: return false
3315
3914
  if (!applied.add(target)) continue
3316
3915
  val document = screen.mutations.node(target) ?: return false
3317
3916
  val path = screen.nodePaths[target] ?: return false
@@ -3327,6 +3926,17 @@ class MainActivity : AppCompatActivity() {
3327
3926
  return true
3328
3927
  }
3329
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
+
3330
3940
  private fun scheduleLayoutEvents(screen: Screen) {
3331
3941
  if (!screen.active) return
3332
3942
  val observer = screen.view.viewTreeObserver
@@ -3465,8 +4075,8 @@ class MainActivity : AppCompatActivity() {
3465
4075
  configureText(this, childText(children), style)
3466
4076
  }
3467
4077
  "StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3468
- screen.statusBarStyle = props.optString("barStyle").takeIf { it.isNotBlank() }
3469
- 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
3470
4080
  visibility = View.GONE
3471
4081
  }
3472
4082
  "RefreshControl" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
@@ -3474,19 +4084,19 @@ class MainActivity : AppCompatActivity() {
3474
4084
  }
3475
4085
  "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
3476
4086
  includeFontPadding = false
3477
- configureText(this, props.optString("title").ifBlank { childText(children) }, style)
3478
- 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"))
3479
4089
  setTextColor(color(linkColor, Color.rgb(33, 150, 243)))
3480
4090
  paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG
3481
4091
  isEnabled = !props.optBoolean("disabled", false)
3482
4092
  isClickable = true
3483
4093
  isFocusable = true
3484
- val target = listOf(props.optString("href"), props.optString("to"))
3485
- .firstOrNull { it.isNotBlank() }
4094
+ val target = listOf(props.opt("href"), props.opt("to"))
4095
+ .firstNotNullOfOrNull(::craftNativeOptionalString)
3486
4096
  ?.trim()
3487
4097
  ?.takeIf { !props.optBoolean("disabled", false) }
3488
4098
  if (target != null) screen.linkTargets[this] = target else screen.linkTargets.remove(this)
3489
- val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
4099
+ val handler = craftNativeEventName(events, "onPress", "onClick")
3490
4100
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3491
4101
  setOnClickListener {
3492
4102
  screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
@@ -3505,12 +4115,12 @@ class MainActivity : AppCompatActivity() {
3505
4115
  minimumHeight = 0
3506
4116
  setPadding(0, 0, 0, 0)
3507
4117
  }).apply {
3508
- val title = props.optString("title").ifBlank { childText(children) }
4118
+ val title = craftNativeOptionalString(props.opt("title")) ?: childText(children)
3509
4119
  isAllCaps = false
3510
4120
  configureText(this, title, style)
3511
- props.optString("color").takeIf { it.isNotBlank() }?.let { setTextColor(color(it, defaultTextColor)) }
4121
+ craftNativeOptionalString(props.opt("color"))?.let { setTextColor(color(it, defaultTextColor)) }
3512
4122
  isEnabled = !props.optBoolean("disabled", false)
3513
- val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
4123
+ val handler = craftNativeEventName(events, "onPress", "onClick")
3514
4124
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3515
4125
  setOnClickListener {
3516
4126
  val current = screen.handlers[this] ?: return@setOnClickListener
@@ -3518,7 +4128,7 @@ class MainActivity : AppCompatActivity() {
3518
4128
  }
3519
4129
  }
3520
4130
  "Checkbox" -> (previous as? CheckBox ?: CheckBox(this)).apply {
3521
- text = props.optString("label").ifBlank { childText(children) }
4131
+ text = craftNativeOptionalString(props.opt("label")) ?: childText(children)
3522
4132
  configureText(this, text.toString(), style)
3523
4133
  val requested = props.opt("checked") ?: props.opt("value")
3524
4134
  val checked = when (requested) {
@@ -3534,8 +4144,10 @@ class MainActivity : AppCompatActivity() {
3534
4144
  isChecked = checked
3535
4145
  isActivated = props.optBoolean("indeterminate", false)
3536
4146
  isEnabled = !props.optBoolean("disabled", false)
3537
- props.optString("color").takeIf { it.isNotBlank() }?.let { buttonTintList = ColorStateList.valueOf(color(it, defaultTextColor)) }
3538
- 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")
3539
4151
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3540
4152
  setOnCheckedChangeListener { _, value ->
3541
4153
  screen.handlers[this]?.let { current ->
@@ -3556,13 +4168,13 @@ class MainActivity : AppCompatActivity() {
3556
4168
  hintTextColors ?: ColorStateList.valueOf(Color.GRAY)
3557
4169
  }
3558
4170
  val defaultHighlightColor = inputHighlightDefaults.getOrPut(this) { highlightColor }
3559
- hint = props.optString("placeholder")
4171
+ hint = craftNativeOptionalString(props.opt("placeholder")).orEmpty()
3560
4172
  setHintTextColor(
3561
- props.optString("placeholderTextColor").takeIf { it.isNotBlank() }
4173
+ craftNativeOptionalString(props.opt("placeholderTextColor"))
3562
4174
  ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
3563
4175
  ?: defaultHintColors,
3564
4176
  )
3565
- highlightColor = props.optString("selectionColor").takeIf { it.isNotBlank() }
4177
+ highlightColor = craftNativeOptionalString(props.opt("selectionColor"))
3566
4178
  ?.let { color(it, defaultHighlightColor) }
3567
4179
  ?: defaultHighlightColor
3568
4180
  val maxLength = props.optInt("maxLength", 0)
@@ -3584,14 +4196,14 @@ class MainActivity : AppCompatActivity() {
3584
4196
  // the web; without one the field keeps its own.
3585
4197
  if (listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom").any { style.has(it) }) {
3586
4198
  val density = resources.displayMetrics.density
3587
- val all = style.optDouble("padding", 0.0)
3588
- val horizontal = style.optDouble("paddingHorizontal", all)
3589
- 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)
3590
4202
  setPadding(
3591
- (style.optDouble("paddingLeft", horizontal) * density).toInt(),
3592
- (style.optDouble("paddingTop", vertical) * density).toInt(),
3593
- (style.optDouble("paddingRight", horizontal) * density).toInt(),
3594
- (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),
3595
4207
  )
3596
4208
  }
3597
4209
  // Reused EditTexts must forget the previous field's hint as
@@ -3612,15 +4224,15 @@ class MainActivity : AppCompatActivity() {
3612
4224
  else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) setText(props.optString("defaultValue"))
3613
4225
  applyTextDecoration(this, style)
3614
4226
  screen.inputIdentities[this] = identity
3615
- val handler = events.optString("onChange").ifBlank { events.optString("onChangeText") }
4227
+ val handler = craftNativeEventName(events, "onChange", "onChangeText")
3616
4228
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3617
- val onFocus = events.optString("onFocus")
4229
+ val onFocus = craftNativeEventName(events, "onFocus")
3618
4230
  if (onFocus.isNotBlank()) screen.focusHandlers[this] = onFocus else screen.focusHandlers.remove(this)
3619
- val onBlur = events.optString("onBlur")
4231
+ val onBlur = craftNativeEventName(events, "onBlur")
3620
4232
  if (onBlur.isNotBlank()) screen.blurHandlers[this] = onBlur else screen.blurHandlers.remove(this)
3621
- val onEndEditing = events.optString("onEndEditing")
4233
+ val onEndEditing = craftNativeEventName(events, "onEndEditing")
3622
4234
  if (onEndEditing.isNotBlank()) screen.endEditingHandlers[this] = onEndEditing else screen.endEditingHandlers.remove(this)
3623
- val onSubmit = events.optString("onSubmitEditing")
4235
+ val onSubmit = craftNativeEventName(events, "onSubmitEditing")
3624
4236
  if (onSubmit.isNotBlank()) screen.submitHandlers[this] = onSubmit else screen.submitHandlers.remove(this)
3625
4237
  setOnFocusChangeListener { view, focused ->
3626
4238
  val nativeEvent = JSONObject().put("text", text.toString())
@@ -3681,10 +4293,10 @@ class MainActivity : AppCompatActivity() {
3681
4293
  }
3682
4294
  if (label.isBlank()) return@mapNotNull null
3683
4295
  val optionProps = option.optJSONObject("props")
3684
- val value = optionProps?.optString("value").takeIf { !it.isNullOrBlank() } ?: label
4296
+ val value = craftNativeOptionalString(optionProps?.opt("value")) ?: label
3685
4297
  value to label
3686
4298
  }
3687
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
4299
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
3688
4300
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3689
4301
  // Detach the old listener before replacing the adapter or selection.
3690
4302
  // Both operations can synchronously dispatch an Android selection
@@ -3693,7 +4305,8 @@ class MainActivity : AppCompatActivity() {
3693
4305
  adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_spinner_item, options.map { it.second }).also {
3694
4306
  it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
3695
4307
  }
3696
- val requested = props.optString("selectedValue").ifBlank { props.optString("value") }
4308
+ val requested = craftNativeOptionalString(props.opt("selectedValue"))
4309
+ ?: craftNativeOptionalString(props.opt("value")).orEmpty()
3697
4310
  val selected = options.indexOfFirst { it.first == requested }.takeIf { it >= 0 } ?: 0
3698
4311
  isEnabled = !props.optBoolean("disabled", false)
3699
4312
  // Mounting a controlled picker must not look like a user
@@ -3716,7 +4329,7 @@ class MainActivity : AppCompatActivity() {
3716
4329
  }
3717
4330
  "DatePicker" -> (previous as? CraftNativeDatePickerView ?: CraftNativeDatePickerView(this)).apply {
3718
4331
  configure(props)
3719
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
4332
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
3720
4333
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3721
4334
  onValueChanged = {
3722
4335
  screen.handlers[this]?.let { current ->
@@ -3730,7 +4343,7 @@ class MainActivity : AppCompatActivity() {
3730
4343
  reconcileChildren(screen, content, children, path, next, style, previousControls)
3731
4344
  // Dynamic :visible bindings arrive as a display style mutation;
3732
4345
  // static visible props remain authoritative when present.
3733
- val visible = if (props.has("visible")) {
4346
+ val visible = if (props.has("visible") && !props.isNull("visible")) {
3734
4347
  // An explicit visible prop owns modal visibility. The
3735
4348
  // binding runtime may leave a stale display:none style
3736
4349
  // from the previous hidden state when it patches props.
@@ -3740,18 +4353,22 @@ class MainActivity : AppCompatActivity() {
3740
4353
  }
3741
4354
  val wasVisible = screen.modalVisibility[this]
3742
4355
  screen.modalVisibility[this] = visible
3743
- val requestClose = events.optString("onRequestClose")
4356
+ val requestClose = craftNativeOptionalString(events.opt("onRequestClose")).orEmpty()
3744
4357
  if (requestClose.isNotBlank()) screen.modalCloseHandlers[this] = requestClose else screen.modalCloseHandlers.remove(this)
3745
4358
  onRequestClose = if (requestClose.isNotBlank()) {
3746
4359
  { send(screen, "EVENT", JSONObject().put("handlerName", requestClose).put("nativeEvent", JSONObject())) }
3747
4360
  } else {
3748
4361
  null
3749
4362
  }
3750
- setVisible(visible, style.optDouble("opacity", 1.0).toFloat(), props.optString("animationType").ifBlank { null })
4363
+ setVisible(visible, craftNativeFiniteFloat(style, "opacity", 1f), craftNativeOptionalString(props.opt("animationType")))
3751
4364
  if (visible) clearDismissSuppression()
3752
4365
  val suppressDismiss = !visible && consumeDismissSuppression()
3753
4366
  if (wasVisible != visible && (visible || wasVisible == true)) {
3754
- 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
+ }
3755
4372
  if (lifecycleHandler.isNotBlank() && !suppressDismiss) {
3756
4373
  // Deliver lifecycle callbacks after reconciliation returns to
3757
4374
  // the queue; the handler may immediately schedule another
@@ -3766,8 +4383,8 @@ class MainActivity : AppCompatActivity() {
3766
4383
  }
3767
4384
  "TouchableOpacity", "TouchableHighlight", "Pressable" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3768
4385
  configureContainer(this, style)
3769
- screen.pressActiveOpacities[this] = props.optDouble("activeOpacity", 0.5).toFloat()
3770
- 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)
3771
4388
  reconcileChildren(screen, this, children, path, next, style, previousControls)
3772
4389
  }
3773
4390
  "Switch" -> (previous as? Switch ?: Switch(this)).apply {
@@ -3791,9 +4408,10 @@ class MainActivity : AppCompatActivity() {
3791
4408
  intArrayOf(trueColor, falseColor),
3792
4409
  )
3793
4410
  } ?: run { trackTintList = null }
3794
- thumbTintList = props.optString("thumbColor").takeIf { it.isNotBlank() }
4411
+ craftNativeOptionalString(props.opt("thumbColor"))
3795
4412
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
3796
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
4413
+ ?: run { thumbTintList = null }
4414
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
3797
4415
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3798
4416
  setOnCheckedChangeListener { _, checked ->
3799
4417
  val current = screen.handlers[this] ?: return@setOnCheckedChangeListener
@@ -3801,11 +4419,11 @@ class MainActivity : AppCompatActivity() {
3801
4419
  }
3802
4420
  }
3803
4421
  "Slider" -> (previous as? SeekBar ?: SeekBar(this)).apply {
3804
- val minimum = props.optDouble("minimumValue", 0.0)
3805
- val maximumCandidate = props.optDouble("maximumValue", 1.0)
4422
+ val minimum = craftNativeFiniteDouble(props, "minimumValue", 0.0)
4423
+ val maximumCandidate = craftNativeFiniteDouble(props, "maximumValue", 1.0)
3806
4424
  val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
3807
4425
  setOnSeekBarChangeListener(null)
3808
- val step = props.optDouble("step", 0.0).takeIf { it > 0 }
4426
+ val step = craftNativeFiniteDouble(props, "step", 0.0).takeIf { it > 0 }
3809
4427
  // A stepped slider needs one native position per authored
3810
4428
  // step; a fixed 1,000-position SeekBar silently made large
3811
4429
  // ranges unable to represent most valid values. Continuous
@@ -3819,7 +4437,7 @@ class MainActivity : AppCompatActivity() {
3819
4437
  .toInt()
3820
4438
  } ?: 1000
3821
4439
  max = progressMax
3822
- val requested = props.optDouble("value", minimum)
4440
+ val requested = craftNativeFiniteDouble(props, "value", minimum)
3823
4441
  val value = (if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested)
3824
4442
  .coerceIn(minimum, maximum)
3825
4443
  progress = if (step != null) {
@@ -3828,16 +4446,19 @@ class MainActivity : AppCompatActivity() {
3828
4446
  (((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
3829
4447
  }
3830
4448
  if (step != null) screen.sliderSteps[this] = step else screen.sliderSteps.remove(this)
3831
- progressTintList = props.optString("minimumTrackTintColor").takeIf { it.isNotBlank() }
4449
+ craftNativeOptionalString(props.opt("minimumTrackTintColor"))
3832
4450
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
3833
- progressBackgroundTintList = props.optString("maximumTrackTintColor").takeIf { it.isNotBlank() }
4451
+ ?: run { progressTintList = null }
4452
+ craftNativeOptionalString(props.opt("maximumTrackTintColor"))
3834
4453
  ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
3835
- thumbTintList = props.optString("thumbTintColor").takeIf { it.isNotBlank() }
4454
+ ?: run { progressBackgroundTintList = null }
4455
+ craftNativeOptionalString(props.opt("thumbTintColor"))
3836
4456
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
4457
+ ?: run { thumbTintList = null }
3837
4458
  isEnabled = !props.optBoolean("disabled", false)
3838
- val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
4459
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
3839
4460
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3840
- val complete = events.optString("onSlidingComplete")
4461
+ val complete = craftNativeEventName(events, "onSlidingComplete")
3841
4462
  if (complete.isNotBlank()) screen.sliderCompleteHandlers[this] = complete else screen.sliderCompleteHandlers.remove(this)
3842
4463
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
3843
4464
  override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
@@ -3864,7 +4485,7 @@ class MainActivity : AppCompatActivity() {
3864
4485
  val scale = when (requestedSize) {
3865
4486
  "large" -> 1.35f
3866
4487
  "small" -> 0.75f
3867
- 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
3868
4489
  else -> 1f
3869
4490
  }
3870
4491
  scaleX = scale
@@ -3872,44 +4493,45 @@ class MainActivity : AppCompatActivity() {
3872
4493
  val animating = props.optBoolean("animating", true)
3873
4494
  isIndeterminate = animating
3874
4495
  visibility = if (!animating && props.optBoolean("hidesWhenStopped", true)) View.GONE else View.VISIBLE
3875
- indeterminateTintList = props.optString("color").takeIf { it.isNotBlank() }
4496
+ craftNativeOptionalString(props.opt("color"))
3876
4497
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
4498
+ ?: run { indeterminateTintList = null }
3877
4499
  }
3878
4500
  "Icon" -> (previous as? CraftNativeIconView ?: CraftNativeIconView(this)).apply {
3879
4501
  configureIcon(this, props, style)
3880
4502
  }
3881
4503
  "Image" -> (previous as? ImageView ?: ImageView(this)).apply {
3882
- scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
4504
+ scaleType = when (craftNativeResizeMode(style, props)) {
3883
4505
  "cover" -> ImageView.ScaleType.CENTER_CROP
3884
4506
  "stretch" -> ImageView.ScaleType.FIT_XY
3885
4507
  "center" -> ImageView.ScaleType.CENTER
3886
4508
  else -> ImageView.ScaleType.CENTER_INSIDE
3887
4509
  }
3888
4510
  adjustViewBounds = false
3889
- val onLoadStart = events.optString("onLoadStart")
4511
+ val onLoadStart = craftNativeEventName(events, "onLoadStart")
3890
4512
  if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[this] = onLoadStart else screen.imageLoadStartHandlers.remove(this)
3891
- val onLoad = events.optString("onLoad")
4513
+ val onLoad = craftNativeEventName(events, "onLoad")
3892
4514
  if (onLoad.isNotBlank()) screen.imageLoadHandlers[this] = onLoad else screen.imageLoadHandlers.remove(this)
3893
- val onLoadEnd = events.optString("onLoadEnd")
4515
+ val onLoadEnd = craftNativeEventName(events, "onLoadEnd")
3894
4516
  if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[this] = onLoadEnd else screen.imageLoadEndHandlers.remove(this)
3895
- val onError = events.optString("onError")
4517
+ val onError = craftNativeEventName(events, "onError")
3896
4518
  if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
3897
4519
  updateImage(screen, this, props.opt("source"), props.opt("defaultSource"))
3898
4520
  }
3899
4521
  "ImageBackground" -> (previous as? CraftNativeImageBackgroundView ?: CraftNativeImageBackgroundView(this)).apply {
3900
- image.scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
4522
+ image.scaleType = when (craftNativeResizeMode(style, props)) {
3901
4523
  "cover" -> ImageView.ScaleType.CENTER_CROP
3902
4524
  "stretch" -> ImageView.ScaleType.FIT_XY
3903
4525
  "center" -> ImageView.ScaleType.CENTER
3904
4526
  else -> ImageView.ScaleType.CENTER_INSIDE
3905
4527
  }
3906
- val onLoadStart = events.optString("onLoadStart")
4528
+ val onLoadStart = craftNativeEventName(events, "onLoadStart")
3907
4529
  if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[image] = onLoadStart else screen.imageLoadStartHandlers.remove(image)
3908
- val onLoad = events.optString("onLoad")
4530
+ val onLoad = craftNativeEventName(events, "onLoad")
3909
4531
  if (onLoad.isNotBlank()) screen.imageLoadHandlers[image] = onLoad else screen.imageLoadHandlers.remove(image)
3910
- val onLoadEnd = events.optString("onLoadEnd")
4532
+ val onLoadEnd = craftNativeEventName(events, "onLoadEnd")
3911
4533
  if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[image] = onLoadEnd else screen.imageLoadEndHandlers.remove(image)
3912
- val onError = events.optString("onError")
4534
+ val onError = craftNativeEventName(events, "onError")
3913
4535
  if (onError.isNotBlank()) screen.imageErrorHandlers[image] = onError else screen.imageErrorHandlers.remove(image)
3914
4536
  updateImage(screen, image, props.opt("source"), props.opt("defaultSource"))
3915
4537
  configureContainer(content, style)
@@ -3923,6 +4545,7 @@ class MainActivity : AppCompatActivity() {
3923
4545
  loop = props.optBoolean("loop", false),
3924
4546
  muted = props.optBoolean("muted", false),
3925
4547
  controls = props.optBoolean("controls", false),
4548
+ resizeMode = craftNativeResizeMode(style, props),
3926
4549
  )
3927
4550
  }
3928
4551
  "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
@@ -3956,22 +4579,23 @@ class MainActivity : AppCompatActivity() {
3956
4579
  .firstOrNull { canonicalType(it.optString("type")) == "RefreshControl" }
3957
4580
  val refreshProps = refreshNode?.optJSONObject("props") ?: JSONObject()
3958
4581
  val refreshEvents = refreshNode?.optJSONObject("events") ?: JSONObject()
3959
- val refreshHandlerName = refreshEvents.optString("onRefresh").takeIf { it.isNotBlank() }
3960
- ?: events.optString("onRefresh").takeIf { it.isNotBlank() }
4582
+ val refreshHandlerName = craftNativeEventName(refreshEvents, "onRefresh")
4583
+ .takeIf { it.isNotBlank() }
4584
+ ?: craftNativeEventName(events, "onRefresh").takeIf { it.isNotBlank() }
3961
4585
  val refreshHandler = refreshHandlerName?.let { handler ->
3962
4586
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
3963
4587
  }
3964
4588
  setRefreshHandler(refreshHandler, if (refreshNode == null) props.optBoolean("refreshing", false) else refreshProps.optBoolean("refreshing", false))
3965
4589
  setRefreshAppearance(refreshProps, resources.displayMetrics.density)
3966
- val onScroll = events.optString("onScroll")
4590
+ val onScroll = craftNativeEventName(events, "onScroll")
3967
4591
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
3968
- val onBegin = events.optString("onScrollBeginDrag")
4592
+ val onBegin = craftNativeEventName(events, "onScrollBeginDrag")
3969
4593
  if (onBegin.isNotBlank()) screen.scrollBeginHandlers[this] = onBegin else screen.scrollBeginHandlers.remove(this)
3970
- val onEnd = events.optString("onScrollEndDrag")
4594
+ val onEnd = craftNativeEventName(events, "onScrollEndDrag")
3971
4595
  if (onEnd.isNotBlank()) screen.scrollEndHandlers[this] = onEnd else screen.scrollEndHandlers.remove(this)
3972
- val onMomentumBegin = events.optString("onMomentumScrollBegin")
4596
+ val onMomentumBegin = craftNativeEventName(events, "onMomentumScrollBegin")
3973
4597
  if (onMomentumBegin.isNotBlank()) screen.scrollMomentumBeginHandlers[this] = onMomentumBegin else screen.scrollMomentumBeginHandlers.remove(this)
3974
- val onMomentumEnd = events.optString("onMomentumScrollEnd")
4598
+ val onMomentumEnd = craftNativeEventName(events, "onMomentumScrollEnd")
3975
4599
  if (onMomentumEnd.isNotBlank()) screen.scrollMomentumEndHandlers[this] = onMomentumEnd else screen.scrollMomentumEndHandlers.remove(this)
3976
4600
  val nativeScroll = this
3977
4601
  setScrollListeners(
@@ -4025,7 +4649,7 @@ class MainActivity : AppCompatActivity() {
4025
4649
  }
4026
4650
  "FlatList" -> (previous as? CraftNativeRefreshableFlatList ?: CraftNativeRefreshableFlatList(this)).apply {
4027
4651
  val nativeList = list
4028
- configureRefresh(events.optString("onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
4652
+ configureRefresh(craftNativeEventName(events, "onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
4029
4653
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
4030
4654
  }, props.optBoolean("refreshing", false))
4031
4655
  nativeList.setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
@@ -4057,15 +4681,15 @@ class MainActivity : AppCompatActivity() {
4057
4681
  } else if (control is CraftNativeKeyboardAvoidingView) {
4058
4682
  control.content.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
4059
4683
  }
4060
- val pressInHandler = events.optString("onPressIn").takeIf { it.isNotBlank() }
4061
- 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() }
4062
4686
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
4063
4687
  if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
4064
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") {
4065
- val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
4689
+ val handler = craftNativeEventName(events, "onPress", "onClick")
4066
4690
  val disabled = props.optBoolean("disabled", false)
4067
- val target = listOf(props.optString("href"), props.optString("to"))
4068
- .firstOrNull { it.isNotBlank() }
4691
+ val target = listOf(props.opt("href"), props.opt("to"))
4692
+ .firstNotNullOfOrNull(::craftNativeOptionalString)
4069
4693
  ?.trim()
4070
4694
  ?.takeIf { !disabled && type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") }
4071
4695
  if (target != null) screen.linkTargets[control] = target else screen.linkTargets.remove(control)
@@ -4126,7 +4750,7 @@ class MainActivity : AppCompatActivity() {
4126
4750
  }
4127
4751
  val longPressDisabled = props.optBoolean("disabled", false) ||
4128
4752
  props.optJSONObject("accessibilityState")?.optBoolean("disabled", false) == true
4129
- val longPressHandler = events.optString("onLongPress").takeUnless { longPressDisabled } ?: ""
4753
+ val longPressHandler = craftNativeEventName(events, "onLongPress").takeUnless { longPressDisabled } ?: ""
4130
4754
  if (longPressHandler.isNotBlank()) {
4131
4755
  screen.longPressHandlers[control] = longPressHandler
4132
4756
  control.setOnLongClickListener {
@@ -4140,7 +4764,7 @@ class MainActivity : AppCompatActivity() {
4140
4764
  control.isLongClickable = false
4141
4765
  }
4142
4766
  applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
4143
- val layoutHandler = events.optString("onLayout")
4767
+ val layoutHandler = craftNativeEventName(events, "onLayout")
4144
4768
  if (layoutHandler.isNotBlank()) {
4145
4769
  if (screen.layoutHandlers[control] != layoutHandler) screen.lastLayoutBounds.remove(control)
4146
4770
  screen.layoutHandlers[control] = layoutHandler
@@ -4176,20 +4800,20 @@ class MainActivity : AppCompatActivity() {
4176
4800
  View.VISIBLE
4177
4801
  }
4178
4802
  }
4179
- val width = style.optDouble("width", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
4180
- 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)
4181
4805
  control.layoutParams = LinearLayout.LayoutParams(
4182
4806
  width ?: ViewGroup.LayoutParams.WRAP_CONTENT,
4183
4807
  height ?: ViewGroup.LayoutParams.WRAP_CONTENT
4184
4808
  ).apply {
4185
- val margin = style.optDouble("margin", 0.0)
4186
- val horizontal = style.optDouble("marginHorizontal", margin)
4187
- 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)
4188
4812
  setMargins(
4189
- dp(style.optDouble("marginLeft", horizontal)),
4190
- dp(style.optDouble("marginTop", vertical)),
4191
- dp(style.optDouble("marginRight", horizontal)),
4192
- 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))
4193
4817
  )
4194
4818
  }
4195
4819
  return control
@@ -4238,19 +4862,18 @@ class MainActivity : AppCompatActivity() {
4238
4862
  val direct = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
4239
4863
  .firstOrNull { it.isNotBlank() }
4240
4864
  if (direct != null) return direct
4241
- val children = node.optJSONArray("children") ?: return null
4242
- for (index in 0 until children.length()) {
4243
- val child = children.optJSONObject(index) ?: continue
4244
- val childProps = child.optJSONObject("props") ?: JSONObject()
4245
- authoredKey(child, childProps)?.let { return it }
4246
- }
4247
- return node.optString("id").takeIf { it.isNotBlank() }
4865
+ return null
4248
4866
  }
4249
4867
 
4250
4868
  private fun configureContainer(layout: CraftNativeFlexLayout, style: JSONObject) {
4251
4869
  layout.configure(style, resources.displayMetrics.density)
4252
4870
  }
4253
4871
 
4872
+ private fun craftNativeResizeMode(style: JSONObject, props: JSONObject): String {
4873
+ return craftNativeOptionalString(style.opt("resizeMode"))
4874
+ ?: craftNativeOptionalString(props.opt("resizeMode")).orEmpty()
4875
+ }
4876
+
4254
4877
  private fun flatListScrollEvent(list: CraftNativeFlatList): JSONObject = JSONObject()
4255
4878
  .put("contentOffset", JSONObject()
4256
4879
  .put("x", list.computeHorizontalScrollOffset())
@@ -4323,30 +4946,29 @@ class MainActivity : AppCompatActivity() {
4323
4946
  }
4324
4947
  val nodes = (0 until children.length()).mapNotNull { children.optJSONObject(it) }
4325
4948
  nodes.forEach { registerFlatListOwnership(screen, it, owner) }
4326
- list.onScrollEvent = events.optString("onScroll").takeIf { it.isNotBlank() }?.let { handler ->
4949
+ list.onScrollEvent = craftNativeEventName(events, "onScroll").takeIf { it.isNotBlank() }?.let { handler ->
4327
4950
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4328
4951
  }
4329
- list.onScrollBeginDrag = events.optString("onScrollBeginDrag").takeIf { it.isNotBlank() }?.let { handler ->
4952
+ list.onScrollBeginDrag = craftNativeEventName(events, "onScrollBeginDrag").takeIf { it.isNotBlank() }?.let { handler ->
4330
4953
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4331
4954
  }
4332
- list.onScrollEndDrag = events.optString("onScrollEndDrag").takeIf { it.isNotBlank() }?.let { handler ->
4955
+ list.onScrollEndDrag = craftNativeEventName(events, "onScrollEndDrag").takeIf { it.isNotBlank() }?.let { handler ->
4333
4956
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4334
4957
  }
4335
- list.onMomentumScrollBegin = events.optString("onMomentumScrollBegin").takeIf { it.isNotBlank() }?.let { handler ->
4958
+ list.onMomentumScrollBegin = craftNativeEventName(events, "onMomentumScrollBegin").takeIf { it.isNotBlank() }?.let { handler ->
4336
4959
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4337
4960
  }
4338
- list.onMomentumScrollEnd = events.optString("onMomentumScrollEnd").takeIf { it.isNotBlank() }?.let { handler ->
4961
+ list.onMomentumScrollEnd = craftNativeEventName(events, "onMomentumScrollEnd").takeIf { it.isNotBlank() }?.let { handler ->
4339
4962
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", flatListScrollEvent(list))) }
4340
4963
  }
4964
+ val threshold = craftNativeNonNegativeDouble(props, "threshold", 0.1)
4965
+ val authoredThreshold = craftNativeNonNegativeDouble(props, "onEndReachedThreshold", threshold)
4341
4966
  list.apply(
4342
4967
  nodes = nodes,
4343
4968
  horizontal = props.optBoolean("horizontal", false),
4344
4969
  columns = props.optInt("numColumns", 1),
4345
4970
  inverted = props.optBoolean("inverted", false),
4346
- endReachedThreshold = props.opt("onEndReachedThreshold")
4347
- ?.takeUnless { it == JSONObject.NULL }
4348
- ?.let { props.optDouble("onEndReachedThreshold", 0.1) }
4349
- ?: props.optDouble("threshold", 0.1),
4971
+ endReachedThreshold = authoredThreshold,
4350
4972
  renderItem = { node, itemIdentity, _ ->
4351
4973
  val rows = screen.flatListRows.getOrPut(list) { mutableMapOf() }
4352
4974
  val previous = rows[itemIdentity].orEmpty()
@@ -4360,7 +4982,7 @@ class MainActivity : AppCompatActivity() {
4360
4982
  val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
4361
4983
  releaseViewState(screen, controls.values.toList(), preservingInputDrafts = true)
4362
4984
  },
4363
- endReached = events.optString("onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
4985
+ endReached = craftNativeEventName(events, "onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
4364
4986
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
4365
4987
  },
4366
4988
  )
@@ -4380,20 +5002,21 @@ class MainActivity : AppCompatActivity() {
4380
5002
  private fun addJustificationSpacers(layout: CraftNativeFlexLayout, value: String) { }
4381
5003
 
4382
5004
  private fun applyViewStyle(view: View, style: JSONObject) {
4383
- view.alpha = style.optDouble("opacity", 1.0).toFloat()
5005
+ view.alpha = craftNativeFiniteFloat(style, "opacity", 1f)
4384
5006
  // A Text's padding (`px-4` on a line of text) is where its text
4385
5007
  // starts, as on iOS; containers and fields lay out their own.
4386
5008
  if (view is TextView && view !is EditText && view !is CraftNativeIconView) {
4387
5009
  val keys = listOf("padding", "paddingHorizontal", "paddingVertical", "paddingLeft", "paddingRight", "paddingTop", "paddingBottom")
4388
5010
  if (keys.any(style::has)) {
4389
- val all = style.optDouble("padding", 0.0)
4390
- val horizontal = style.optDouble("paddingHorizontal", all)
4391
- 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)
4392
5015
  view.setPadding(
4393
- dp(style.optDouble("paddingLeft", horizontal)),
4394
- dp(style.optDouble("paddingTop", vertical)),
4395
- dp(style.optDouble("paddingRight", horizontal)),
4396
- 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),
4397
5020
  )
4398
5021
  } else if (view.javaClass == TextView::class.java) {
4399
5022
  view.setPadding(0, 0, 0, 0)
@@ -4403,18 +5026,18 @@ class MainActivity : AppCompatActivity() {
4403
5026
  if (view !is CraftNativeModalView) {
4404
5027
  view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
4405
5028
  }
4406
- view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
4407
- 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)
4408
5031
  val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
4409
- val border = style.optDouble("borderWidth", 0.0)
4410
- val radius = style.optDouble("borderRadius", 0.0)
5032
+ val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
5033
+ val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
4411
5034
  val sideBorderKeys = listOf("borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth")
4412
5035
  val hasSideBorders = (sideBorderKeys + listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")).any(style::has)
4413
- val sideWidths = sideBorderKeys.map { dp(style.optDouble(it, border)) }.toIntArray()
5036
+ val sideWidths = sideBorderKeys.map { dp(craftNativeNonNegativeDouble(style, it, border)) }.toIntArray()
4414
5037
  val cornerRadiusKeys = listOf("borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius")
4415
5038
  val hasCornerRadii = cornerRadiusKeys.any(style::has)
4416
5039
  val cornerRadii = cornerRadiusKeys.flatMap { value ->
4417
- val radius = dp(style.optDouble(value, radius)).toFloat()
5040
+ val radius = dp(craftNativeNonNegativeDouble(style, value, radius)).toFloat()
4418
5041
  listOf(radius, radius)
4419
5042
  }.toFloatArray()
4420
5043
  val sideColors = listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")
@@ -4457,19 +5080,20 @@ class MainActivity : AppCompatActivity() {
4457
5080
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
4458
5081
  (style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
4459
5082
  ) {
4460
- val shadowOpacity = style.optDouble("shadowOpacity", 1.0).coerceIn(0.0, 1.0)
5083
+ val shadowOpacity = craftNativeFiniteFloat(style, "shadowOpacity", 1f).coerceIn(0f, 1f)
4461
5084
  val shadow = color(style.optString("shadowColor"), Color.BLACK)
4462
5085
  val alphaShadow = Color.argb((Color.alpha(shadow) * shadowOpacity).roundToInt(), Color.red(shadow), Color.green(shadow), Color.blue(shadow))
4463
5086
  view.outlineAmbientShadowColor = alphaShadow
4464
5087
  view.outlineSpotShadowColor = alphaShadow
4465
- 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())
4466
5090
  val offset = style.optJSONObject("shadowOffset")
4467
- val offsetX = dp(offset?.optDouble("width", 0.0) ?: 0.0)
4468
- val offsetY = dp(offset?.optDouble("height", 0.0) ?: 0.0)
4469
- 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()
4470
5094
  view.outlineProvider = object : ViewOutlineProvider() {
4471
5095
  override fun getOutline(target: View, outline: Outline) {
4472
- outline.setRoundRect(offsetX, offsetY, target.width + offsetX, target.height + offsetY, shadowRadius)
5096
+ outline.setRoundRect(offsetX, offsetY, target.width + offsetX, target.height + offsetY, radius)
4473
5097
  }
4474
5098
  }
4475
5099
  view.invalidateOutline()
@@ -4502,11 +5126,11 @@ class MainActivity : AppCompatActivity() {
4502
5126
  val transform = transforms.optJSONObject(index) ?: continue
4503
5127
  val key = transform.keys().asSequence().firstOrNull() ?: continue
4504
5128
  when (key) {
4505
- "translateX" -> view.translationX += dp(transform.optDouble(key, 0.0)).toFloat()
4506
- "translateY" -> view.translationY += dp(transform.optDouble(key, 0.0)).toFloat()
4507
- "scale" -> transform.optDouble(key, 1.0).toFloat().let { view.scaleX *= it; view.scaleY *= it }
4508
- "scaleX" -> view.scaleX *= transform.optDouble(key, 1.0).toFloat()
4509
- "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()
4510
5134
  "rotate", "rotateZ" -> view.rotation += angleDegrees(transform.opt(key))
4511
5135
  "rotateX" -> view.rotationX += angleDegrees(transform.opt(key))
4512
5136
  "rotateY" -> view.rotationY += angleDegrees(transform.opt(key))
@@ -4518,15 +5142,16 @@ class MainActivity : AppCompatActivity() {
4518
5142
  }
4519
5143
 
4520
5144
  private fun skewFactor(value: Any?): Float =
4521
- 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
4522
5146
 
4523
5147
  private fun angleDegrees(value: Any?): Float {
4524
5148
  val text = value?.toString()?.trim() ?: return 0f
4525
- return when {
5149
+ val result = when {
4526
5150
  text.endsWith("rad") -> Math.toDegrees(text.dropLast(3).toDoubleOrNull() ?: 0.0).toFloat()
4527
5151
  text.endsWith("deg") -> text.dropLast(3).toFloatOrNull() ?: 0f
4528
5152
  else -> text.toFloatOrNull() ?: 0f
4529
5153
  }
5154
+ return result.takeIf { it.isFinite() } ?: 0f
4530
5155
  }
4531
5156
 
4532
5157
  private fun ensureLayoutListener(screen: Screen, view: View) {
@@ -4549,15 +5174,22 @@ class MainActivity : AppCompatActivity() {
4549
5174
  }
4550
5175
 
4551
5176
  private fun configureIcon(view: CraftNativeIconView, props: JSONObject, style: JSONObject) {
4552
- val symbol = props.optString("symbol").ifBlank { props.optString("name") }
4553
- 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)
4554
5180
  view.setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
4555
- val foreground = style.optString("color").ifBlank { props.optString("color") }
5181
+ val foreground = craftNativeOptionalString(style.opt("color"))
5182
+ ?: craftNativeOptionalString(props.opt("color")).orEmpty()
4556
5183
  val tint = color(foreground, defaultTextColor)
4557
5184
  view.setTextColor(tint)
4558
5185
  // The app's own vector for it (craft-icons.json, the icon the web
4559
5186
  // shows), else the closest glyph Android's fonts have.
4560
- 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
+ )
4561
5193
  view.vector = vector
4562
5194
  view.text = if (vector != null) "" else androidIconGlyph(symbol)
4563
5195
  val weight = when (style.optString("fontWeight")) {
@@ -4608,7 +5240,7 @@ class MainActivity : AppCompatActivity() {
4608
5240
 
4609
5241
  private fun configureTextStyle(view: TextView, style: JSONObject) {
4610
5242
  view.setTextColor(color(style.optString("color"), defaultTextColor))
4611
- view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 16.0).toFloat())
5243
+ view.setTextSize(TypedValue.COMPLEX_UNIT_SP, craftNativeFiniteFloat(style, "fontSize", 16f))
4612
5244
  val weight = when (style.optString("fontWeight")) {
4613
5245
  "bold" -> 700
4614
5246
  "100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
@@ -4635,11 +5267,11 @@ class MainActivity : AppCompatActivity() {
4635
5267
  }
4636
5268
  }
4637
5269
  view.letterSpacing = style.optDouble("letterSpacing", Double.NaN)
4638
- .takeUnless { it.isNaN() }
5270
+ .takeIf { it.isFinite() }
4639
5271
  ?.let { if (view.textSize > 0) dp(it).toFloat() / view.textSize else 0f }
4640
5272
  ?: 0f
4641
5273
  view.setLineSpacing(
4642
- 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,
4643
5275
  1f
4644
5276
  )
4645
5277
  val shadowColor = style.optString("textShadowColor").takeIf { it.isNotBlank() }
@@ -4648,9 +5280,9 @@ class MainActivity : AppCompatActivity() {
4648
5280
  } else {
4649
5281
  val offset = style.optJSONObject("textShadowOffset")
4650
5282
  view.setShadowLayer(
4651
- dp(style.optDouble("textShadowRadius", 0.0)).toFloat().coerceAtLeast(0f),
4652
- dp(offset?.optDouble("width", 0.0) ?: 0.0).toFloat(),
4653
- 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(),
4654
5286
  color(shadowColor, Color.TRANSPARENT),
4655
5287
  )
4656
5288
  }
@@ -4714,7 +5346,7 @@ class MainActivity : AppCompatActivity() {
4714
5346
 
4715
5347
  private fun imageUri(source: Any?): String = when (source) {
4716
5348
  is String -> source
4717
- is JSONObject -> source.optString("uri")
5349
+ is JSONObject -> craftNativeOptionalString(source.opt("uri")).orEmpty()
4718
5350
  else -> ""
4719
5351
  }
4720
5352
 
@@ -4735,6 +5367,7 @@ class MainActivity : AppCompatActivity() {
4735
5367
  imageSources[view] = ""
4736
5368
  imageErrors[view] = "Image source is missing"
4737
5369
  view.setImageDrawable(null)
5370
+ imageDidChange(view)
4738
5371
  imageFailure(screen, view, uri, "Image source is missing")
4739
5372
  return
4740
5373
  }
@@ -4743,7 +5376,9 @@ class MainActivity : AppCompatActivity() {
4743
5376
  imageSources[view] = uri
4744
5377
  imageErrors.remove(view)
4745
5378
  view.setImageDrawable(null)
5379
+ imageDidChange(view)
4746
5380
  immediateImage(imageUri(defaultSource))?.let(view::setImageBitmap)
5381
+ if (view.drawable != null) imageDidChange(view)
4747
5382
  emitImageEvent(screen, view, screen.imageLoadStartHandlers[view])
4748
5383
 
4749
5384
  if (uri.startsWith("data:image/") && uri.contains(',')) {
@@ -4751,6 +5386,7 @@ class MainActivity : AppCompatActivity() {
4751
5386
  val bitmap = bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
4752
5387
  if (bitmap != null) {
4753
5388
  view.setImageBitmap(bitmap)
5389
+ imageDidChange(view)
4754
5390
  emitImageEvent(screen, view, screen.imageLoadHandlers[view])
4755
5391
  emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
4756
5392
  } else imageFailure(screen, view, uri, "Image data is invalid")
@@ -4760,6 +5396,7 @@ class MainActivity : AppCompatActivity() {
4760
5396
  val bitmap = runCatching { assets.open(uri).use { BitmapFactory.decodeStream(it) } }.getOrNull()
4761
5397
  if (bitmap != null) {
4762
5398
  view.setImageBitmap(bitmap)
5399
+ imageDidChange(view)
4763
5400
  emitImageEvent(screen, view, screen.imageLoadHandlers[view])
4764
5401
  emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
4765
5402
  } else imageFailure(screen, view, uri, "Bundled image was not found")
@@ -4782,6 +5419,7 @@ class MainActivity : AppCompatActivity() {
4782
5419
  imageJobs.remove(view)
4783
5420
  if (bitmap != null) {
4784
5421
  view.setImageBitmap(bitmap)
5422
+ imageDidChange(view)
4785
5423
  emitImageEvent(screen, view, screen.imageLoadHandlers[view])
4786
5424
  emitImageEvent(screen, view, screen.imageLoadEndHandlers[view])
4787
5425
  } else imageFailure(screen, view, uri, "Image download failed")
@@ -4792,6 +5430,7 @@ class MainActivity : AppCompatActivity() {
4792
5430
  private fun imageFailure(screen: Screen, view: ImageView, uri: String, message: String) {
4793
5431
  imageErrors[view] = message
4794
5432
  view.setImageDrawable(null)
5433
+ imageDidChange(view)
4795
5434
  val current = view.contentDescription?.toString().orEmpty()
4796
5435
  if (!current.contains(message)) {
4797
5436
  view.contentDescription = listOf(
@@ -4804,6 +5443,18 @@ class MainActivity : AppCompatActivity() {
4804
5443
  Log.e(TAG, "$message: $uri")
4805
5444
  }
4806
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
+
4807
5458
  private fun emitImageEvent(screen: Screen, view: ImageView, handler: String?, nativeEvent: JSONObject = JSONObject()) {
4808
5459
  if (handler.isNullOrBlank()) return
4809
5460
  send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", nativeEvent))
@@ -4824,17 +5475,17 @@ class MainActivity : AppCompatActivity() {
4824
5475
 
4825
5476
  private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
4826
5477
  view.tag = fallbackLabel
4827
- val label = props.optString("accessibilityLabel").takeIf { it.isNotBlank() }
4828
- ?: props.optString("alt").takeIf { (type == "Image" || type == "ImageBackground") && it.isNotBlank() }
4829
- 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"))
4830
5481
  view.contentDescription = when {
4831
5482
  label != null && value != null -> "$label, $value"
4832
5483
  label != null -> label
4833
5484
  value != null -> value
4834
5485
  else -> null
4835
5486
  }
4836
- view.tooltipText = props.optString("accessibilityHint").takeIf { it.isNotBlank() }
4837
- val role = props.optString("accessibilityRole").ifBlank {
5487
+ view.tooltipText = craftNativeOptionalString(props.opt("accessibilityHint"))
5488
+ val role = craftNativeOptionalString(props.opt("accessibilityRole")) ?: run {
4838
5489
  if (type == "Icon") "image" else type.lowercase()
4839
5490
  }
4840
5491
  val state = props.optJSONObject("accessibilityState")
@@ -4872,7 +5523,10 @@ class MainActivity : AppCompatActivity() {
4872
5523
  )
4873
5524
  }
4874
5525
 
4875
- 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
+ }
4876
5530
  private fun color(raw: String, fallback: Int) = runCatching { craftNativeColor(raw) }.getOrDefault(fallback)
4877
5531
 
4878
5532
  private fun inputTypeFor(props: JSONObject): Int {
@@ -5076,6 +5730,11 @@ class MainActivity : AppCompatActivity() {
5076
5730
  super.onStop()
5077
5731
  }
5078
5732
 
5733
+ override fun onLowMemory() {
5734
+ mapViews().forEach { it.lowMemory() }
5735
+ super.onLowMemory()
5736
+ }
5737
+
5079
5738
  override fun onNewIntent(intent: Intent) {
5080
5739
  super.onNewIntent(intent)
5081
5740
  setIntent(intent)
@@ -5174,10 +5833,40 @@ class MainActivity : AppCompatActivity() {
5174
5833
  screen.port.postMessage(Message.createStringMessage(message.toString()))
5175
5834
  } else {
5176
5835
  val script = "globalThis.__stxNativeCallback && globalThis.__stxNativeCallback(${JSONObject.quote(message.toString())})"
5177
- screen.isolate.evaluateJavaScriptAsync(script)
5836
+ reportJavaScriptFailure(screen, screen.isolate.evaluateJavaScriptAsync(script))
5178
5837
  }
5179
5838
  }
5180
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
+
5181
5870
  /** Back to the first screen in one move. */
5182
5871
  private fun popToRoot() {
5183
5872
  while (screens.size > 1) closeScreen(screens.removeAt(screens.lastIndex))
@@ -5216,6 +5905,11 @@ class MainActivity : AppCompatActivity() {
5216
5905
  screen.flatListRows.values.forEach { rows -> rows.values.forEach { addAll(it.values) } }
5217
5906
  }
5218
5907
 
5908
+ private fun mapViews(): Set<CraftNativeMapView> = allScreens()
5909
+ .flatMap { retainedViews(it) }
5910
+ .filterIsInstance<CraftNativeMapView>()
5911
+ .toSet()
5912
+
5219
5913
  private fun releaseFlatList(
5220
5914
  screen: Screen,
5221
5915
  list: CraftNativeFlatList,
@@ -5247,6 +5941,7 @@ class MainActivity : AppCompatActivity() {
5247
5941
  }
5248
5942
  released.filterIsInstance<CraftNativeModalView>().forEach { it.resetForTeardown() }
5249
5943
  released.filterIsInstance<CraftNativeVideoView>().forEach { it.stop() }
5944
+ released.filterIsInstance<CraftNativeMapView>().forEach { it.destroy() }
5250
5945
  released.filterIsInstance<CraftNativeFlexLayout>().forEach { it.onLayoutChanged = null }
5251
5946
  released.filterIsInstance<CraftNativeKeyboardAvoidingView>().forEach { it.content.onLayoutChanged = null }
5252
5947
  released.filterIsInstance<CraftNativeScrollView>().forEach { it.setRefreshHandler(null, false) }