@craft-native/android 0.0.207 → 0.0.210

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.
package/README.md CHANGED
@@ -102,7 +102,8 @@ stx native compile --config ../screens/native.config.json --format bundle --outp
102
102
  craft android build --native-bundle ../native-screen.js
103
103
  ```
104
104
 
105
- Native mode maps STX `View`, `SafeAreaView`, `Text`, `Button`, `Link`, `Icon`,
105
+ Native mode maps STX `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`,
106
+ `RefreshControl`, `Text`, `Button`, `Link`, `Icon`,
106
107
  `Pressable`, `TouchableOpacity`, `TouchableHighlight`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Modal`, `Switch`, `Slider`, `ActivityIndicator`, `Image`, `ImageBackground`, `Video`, `Svg`, `G`, `SvgText`, `ScrollView`,
107
108
  `Map`, `SectionList`, and `FlatList` to Android views. `Map` uses the Google
108
109
  Maps SDK for trail polylines and marker taps; its overlays carry flat
@@ -199,7 +200,12 @@ JavaScriptSandbox process. Enable `enableLocalDatabase`,
199
200
  `enableLocalNotifications`, and `enableDeepLinks` explicitly. Disabled or
200
201
  malformed calls reject with stable capability/error codes, and in-flight calls
201
202
  have a deadline and cancellation path. The broader browser bridge remains
202
- specific to WebView mode. Native `TextInput` applies `placeholderTextColor`,
203
+ specific to WebView mode. JavaScriptSandbox has no DOM `window`; Craft aliases
204
+ `globalThis.window` to `globalThis` before installing the native bridge so
205
+ browser-safe SDK calls such as `window.craft.dialog.actionSheet()` reach the
206
+ native capability host. This does not add `document` or DOM rendering; routes
207
+ that do not opt into `native` continue using the WebView renderer. Native
208
+ `TextInput` applies `placeholderTextColor`,
203
209
  `selectionColor`, positive `maxLength`, `multiline`, and `numberOfLines` values.
204
210
  Native views include
205
211
  `ActivityIndicator` and expose
@@ -248,7 +254,7 @@ configured start path against `nativeScreens`; invalid native route tables are
248
254
  rejected before a project is generated.
249
255
 
250
256
  - a tab bar and a navigation bar (`craft.navigation.setOptions`: title, large
251
- title, right buttons with badges, `tabBarHidden`), a stack per tab, Android's
257
+ title, right buttons with badges, `tabAccessory`, `tabBarHidden`), a stack per tab, Android's
252
258
  Back key popping it; completed left/right swipes switch between tab roots
253
259
  interactively, yield to a horizontal scroll view, and can be disabled per
254
260
  screen with `craft.navigation.setOptions({ tabSwipeEnabled: false })`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.207",
3
+ "version": "0.0.210",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -389,6 +389,10 @@ internal object CraftNativePrelude {
389
389
  routePrelude: String,
390
390
  ): String = """
391
391
  $routePrelude
392
+ // JavaScriptSandbox has no DOM window, but browser-safe SDK bridges
393
+ // address the host through `window.craft`. Keep native screens
394
+ // compatible without pretending a document exists.
395
+ if (typeof globalThis.window === 'undefined') globalThis.window = globalThis;
392
396
  (function() {
393
397
  var forward = null;
394
398
  var queued = [];
@@ -567,7 +571,7 @@ internal object CraftNativePrelude {
567
571
  * The bar above a screen (back, title, the screen's buttons) and the tab bar
568
572
  * below it, in plain Android views. A screen sets its bar with
569
573
  * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
570
- * tabBarHidden); the tabs come from the app's `tabs`.
574
+ * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`.
571
575
  */
572
576
  internal class CraftNativeChrome(context: Context, private val tabs: List<CraftNativeTab>) : LinearLayout(context) {
573
577
  val content = FrameLayout(context)
@@ -578,6 +582,11 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
578
582
  private val largeTitleView = TextView(context)
579
583
  private val buttons = LinearLayout(context)
580
584
  private val tabBar = LinearLayout(context)
585
+ private val tabRow = LinearLayout(context)
586
+ private val accessory = FrameLayout(context)
587
+ private val accessoryIcon = ImageView(context)
588
+ private val accessoryBadge = TextView(context)
589
+ private var accessorySpec: JSONObject? = null
581
590
  private val tabViews = mutableListOf<Pair<TextView, TextView>>()
582
591
  private val tabIconText = mutableMapOf<String, String>()
583
592
  private val tabImageURLs = mutableMapOf<String, String>()
@@ -596,6 +605,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
596
605
  var onBack: (() -> Unit)? = null
597
606
  var onButton: ((String) -> Unit)? = null
598
607
  var onTab: ((Int) -> Unit)? = null
608
+ var onAccessory: ((String) -> Unit)? = null
599
609
  var onTabSwipeStart: ((Float, Float, Float) -> Boolean)? = null
600
610
  var onTabSwipeMove: ((Float) -> Unit)? = null
601
611
  var onTabSwipeEnd: ((Float, Boolean) -> Unit)? = null
@@ -660,8 +670,22 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
660
670
  tabBar.addView(cell, LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
661
671
  tabViews.add(icon to label)
662
672
  }
663
- addView(tabBar, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(64)))
664
- tabBar.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
673
+ tabRow.orientation = HORIZONTAL
674
+ tabRow.gravity = Gravity.CENTER_VERTICAL
675
+ tabRow.setPadding(dp(12), dp(4), dp(12), dp(4))
676
+ tabRow.addView(tabBar, LayoutParams(0, dp(64), 1f))
677
+ accessory.addView(accessoryIcon, FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
678
+ accessoryBadge.textSize = 10f
679
+ accessoryBadge.gravity = Gravity.CENTER
680
+ accessoryBadge.setTextColor(Color.WHITE)
681
+ accessoryBadge.setPadding(dp(4), 0, dp(4), 0)
682
+ accessoryBadge.background = GradientDrawable().apply { setColor(Color.RED); cornerRadius = dp(9).toFloat() }
683
+ accessory.addView(accessoryBadge, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(18), Gravity.TOP or Gravity.END))
684
+ accessory.setOnClickListener { accessorySpec?.optString("path")?.let { onAccessory?.invoke(it) } }
685
+ accessory.visibility = View.GONE
686
+ tabRow.addView(accessory, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(8) })
687
+ addView(tabRow, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(72)))
688
+ tabRow.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
665
689
  refreshColors()
666
690
  }
667
691
 
@@ -671,7 +695,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
671
695
  val overHorizontalScroll = onTabSwipeShouldDefer?.invoke(event.x, event.y) == true
672
696
  horizontalScrollDeferred = overHorizontalScroll
673
697
  if (overHorizontalScroll) parent?.requestDisallowInterceptTouchEvent(true)
674
- swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabBar.visibility == View.VISIBLE && event.y < tabBar.top
698
+ swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabRow.visibility == View.VISIBLE && event.y < tabRow.top
675
699
  swipeIntercepted = false
676
700
  swipeDownX = event.x
677
701
  swipeDownY = event.y
@@ -742,7 +766,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
742
766
  buttons.addView(navButton(button))
743
767
  }
744
768
  val tabBarHidden = options.optBoolean("tabBarHidden", false) || depth > 0 && options.optBoolean("tabBarHiddenWhenPushed", false)
745
- tabBar.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
769
+ tabRow.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
770
+ accessorySpec = options.optJSONObject("tabAccessory")?.takeIf {
771
+ val path = it.optString("path")
772
+ path.startsWith("/") && !path.startsWith("//") && it.has("title")
773
+ }
774
+ accessory.visibility = if (accessorySpec == null) View.GONE else View.VISIBLE
775
+ accessorySpec?.let {
776
+ accessory.contentDescription = it.optString("accessibilityLabel").ifBlank { it.optString("title") } + it.optString("badge").takeIf { badge -> badge.isNotBlank() && badge != "null" }?.let { badge -> ", $badge" }.orEmpty()
777
+ accessoryBadge.text = it.optString("badge").takeUnless { badge -> badge == "null" }.orEmpty().take(8)
778
+ accessoryBadge.visibility = if (accessoryBadge.text.isEmpty()) View.GONE else View.VISIBLE
779
+ }
746
780
  tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)
747
781
  refreshColors()
748
782
  }
@@ -801,13 +835,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
801
835
  titleView.setTextColor(text)
802
836
  largeTitleView.setTextColor(text)
803
837
  backButton.imageTintList = ColorStateList.valueOf(accent)
804
- tabBar.setBackgroundColor(if (dark) Color.parseColor("#121212") else Color.parseColor("#F9F9F9"))
805
- val divider = GradientDrawable().apply { setColor(if (dark) Color.parseColor("#262626") else Color.parseColor("#D8D8DC")) }
806
- tabBar.foreground = null
807
- tabBar.background = android.graphics.drawable.LayerDrawable(arrayOf(
808
- divider,
809
- GradientDrawable().apply { setColor(if (dark) Color.parseColor("#121212") else Color.parseColor("#F9F9F9")) },
810
- )).apply { setLayerInset(1, 0, maxOf(1, (density / 2).toInt()), 0, 0) }
838
+ val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
839
+ tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
840
+ accessory.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
841
+ accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
811
842
  tabViews.forEachIndexed { index, (icon, label) ->
812
843
  val color = if (index == selectedTab) accent else muted
813
844
  val number = tabIconText[tabs[index].id]
@@ -256,16 +256,9 @@ private data class CraftNativeLayoutStyle(
256
256
  aspectRatio = decimal("aspectRatio")?.takeIf { it.isFinite() && it > 0.0 },
257
257
  minWidth = length("minWidth"), maxWidth = length("maxWidth"),
258
258
  minHeight = length("minHeight"), maxHeight = length("maxHeight"),
259
- position = raw.opt("position")
260
- ?.takeUnless { it == JSONObject.NULL }
261
- ?.toString()
262
- ?.takeIf { it.isNotBlank() }
263
- ?: "relative",
259
+ position = craftNativeOptionalString(raw.opt("position")) ?: "relative",
264
260
  top = number("top"), right = number("right"), bottom = number("bottom"), left = number("left"),
265
- alignSelf = raw.opt("alignSelf")
266
- ?.takeUnless { it == JSONObject.NULL }
267
- ?.toString()
268
- ?.takeIf { it.isNotBlank() && it != "auto" },
261
+ alignSelf = craftNativeOptionalString(raw.opt("alignSelf"))?.takeUnless { it == "auto" },
269
262
  flexGrow = decimal("flexGrow") ?: flex?.coerceAtLeast(0.0) ?: 0.0,
270
263
  flexShrink = decimal("flexShrink") ?: flex?.let { if (it != 0.0) 1.0 else 0.0 },
271
264
  flexBasis = length("flexBasis")
@@ -383,11 +376,8 @@ private class CraftNativeSvgView(context: Context) : View(context) {
383
376
  fun merge(node: JSONObject): SvgPaint {
384
377
  val props = node.optJSONObject("props") ?: JSONObject()
385
378
  val style = node.optJSONObject("style") ?: JSONObject()
386
- fun value(name: String): String? = when {
387
- style.has(name) && !style.isNull(name) -> style.optString(name)
388
- props.has(name) && !props.isNull(name) -> props.optString(name)
389
- else -> null
390
- }
379
+ fun value(name: String): String? = craftNativeOptionalString(style.opt(name))
380
+ ?: craftNativeOptionalString(props.opt(name))
391
381
  fun raw(name: String): Any? = when {
392
382
  style.has(name) && !style.isNull(name) -> style.opt(name)
393
383
  props.has(name) && !props.isNull(name) -> props.opt(name)
@@ -486,7 +476,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
486
476
  for (index in 0 until children.length()) {
487
477
  val node = children.optJSONObject(index) ?: continue
488
478
  val style = node.optJSONObject("style") ?: JSONObject()
489
- if (style.optString("display") == "none") continue
479
+ if (craftNativeOptionalString(style.opt("display")) == "none") continue
490
480
  val paint = inherited.merge(node)
491
481
  when (node.optString("type")) {
492
482
  "G" -> drawNodes(canvas, node.optJSONArray("children") ?: JSONArray(), paint)
@@ -582,7 +572,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
582
572
  }
583
573
  var x = craftNativeNumber(props.opt("x"))?.toFloat()?.takeIf { it.isFinite() } ?: 0f
584
574
  val y = craftNativeNumber(props.opt("y"))?.toFloat()?.takeIf { it.isFinite() } ?: 0f
585
- when (props.optString("textAnchor")) {
575
+ when (craftNativeOptionalString(props.opt("textAnchor"))) {
586
576
  "middle" -> x -= paint.measureText(text) / 2f
587
577
  "end" -> x -= paint.measureText(text)
588
578
  }
@@ -728,12 +718,15 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
728
718
  val explicitRegion = region(pendingProps.optJSONObject("region"))
729
719
  val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
730
720
  if (explicitRegion != null) {
731
- moveCamera(explicitRegion)
721
+ // A controlled region is an exact camera request. The padding
722
+ // used for fitToBounds is framing chrome, not part of the region
723
+ // contract, and would otherwise zoom every Android screen out.
724
+ moveCamera(explicitRegion, padding = 0)
732
725
  initialCameraKey = null
733
726
  } else if (initialRegion != null) {
734
727
  val key = "region:${pendingProps.optJSONObject("initialRegion")}"
735
728
  if (initialCameraKey != key) {
736
- moveCamera(initialRegion)
729
+ moveCamera(initialRegion, padding = 0)
737
730
  initialCameraKey = key
738
731
  }
739
732
  } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
@@ -755,9 +748,9 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
755
748
  }
756
749
  }
757
750
 
758
- private fun moveCamera(bounds: LatLngBounds) {
751
+ private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) {
759
752
  post {
760
- if (width > 0 && height > 0) googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 24))
753
+ if (width > 0 && height > 0) googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding))
761
754
  }
762
755
  }
763
756
 
@@ -1027,14 +1020,15 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1027
1020
  }
1028
1021
 
1029
1022
  fun configure(style: JSONObject, density: Float) {
1030
- orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
1031
- reverse = style.optString("flexDirection").endsWith("-reverse")
1032
- val flexWrap = style.optString("flexWrap")
1033
- wrap = flexWrap == "wrap" || flexWrap == "wrap-reverse" || style.optBoolean("flexWrap", false)
1023
+ val flexDirection = craftNativeOptionalString(style.opt("flexDirection")).orEmpty()
1024
+ orientation = if (flexDirection in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
1025
+ reverse = flexDirection.endsWith("-reverse")
1026
+ val flexWrap = craftNativeOptionalString(style.opt("flexWrap")).orEmpty()
1027
+ wrap = flexWrap == "wrap" || flexWrap == "wrap-reverse" || craftNativeBoolean(style.opt("flexWrap"))
1034
1028
  wrapReverse = flexWrap == "wrap-reverse"
1035
- alignItems = style.optString("alignItems", "stretch")
1036
- alignContent = style.optString("alignContent", "flex-start")
1037
- justifyContent = style.optString("justifyContent", "flex-start")
1029
+ alignItems = craftNativeOptionalString(style.opt("alignItems")) ?: "stretch"
1030
+ alignContent = craftNativeOptionalString(style.opt("alignContent")) ?: "flex-start"
1031
+ justifyContent = craftNativeOptionalString(style.opt("justifyContent")) ?: "flex-start"
1038
1032
  fun spacing(name: String, fallback: Float): Float {
1039
1033
  val value = craftNativeNumber(style.opt(name)) ?: Double.NaN
1040
1034
  val pixels = value * density
@@ -1044,7 +1038,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1044
1038
  val gap = spacing("gap", 0f)
1045
1039
  rowGap = spacing("rowGap", gap)
1046
1040
  columnGap = spacing("columnGap", gap)
1047
- grid = style.optString("display") == "grid"
1041
+ grid = craftNativeOptionalString(style.opt("display")) == "grid"
1048
1042
  gridColumns = craftNativeGridColumnCount(
1049
1043
  if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
1050
1044
  )
@@ -2095,7 +2089,7 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
2095
2089
  }
2096
2090
 
2097
2091
  fun configure(props: JSONObject, style: JSONObject, density: Float) {
2098
- behavior = props.optString("behavior", "padding").takeIf { it in setOf("height", "position", "padding") } ?: "padding"
2092
+ behavior = craftNativeOptionalString(props.opt("behavior"))?.takeIf { it in setOf("height", "position", "padding") } ?: "padding"
2099
2093
  val authoredOffset = craftNativeFiniteDouble(props, "keyboardVerticalOffset", 0.0)
2100
2094
  val pixelOffset = (authoredOffset * density.toDouble()).takeIf { it.isFinite() } ?: 0.0
2101
2095
  keyboardVerticalOffset = pixelOffset.roundToInt()
@@ -3958,6 +3952,7 @@ class MainActivity : AppCompatActivity() {
3958
3952
  val bars = CraftNativeChrome(this, routes.tabs)
3959
3953
  bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
3960
3954
  bars.onBack = { onBackPressedDispatcherCompat() }
3955
+ bars.onAccessory = { path -> navigateOpen(path) }
3961
3956
  bars.onButton = { id -> visibleScreen?.takeIf { it.active }?.let { send(it, "NAV_BUTTON", JSONObject().put("id", id)) } }
3962
3957
  bars.onTab = { index ->
3963
3958
  haptic()
@@ -4945,7 +4940,7 @@ class MainActivity : AppCompatActivity() {
4945
4940
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
4946
4941
  includeFontPadding = false
4947
4942
  maxLines = craftNativeInt(props.opt("numberOfLines"), 0).takeIf { it > 0 } ?: Int.MAX_VALUE
4948
- ellipsize = when (props.optString("ellipsizeMode")) {
4943
+ ellipsize = when (craftNativeOptionalString(props.opt("ellipsizeMode"))) {
4949
4944
  "head" -> TextUtils.TruncateAt.START
4950
4945
  "middle" -> TextUtils.TruncateAt.MIDDLE
4951
4946
  "tail" -> TextUtils.TruncateAt.END
@@ -5248,7 +5243,7 @@ class MainActivity : AppCompatActivity() {
5248
5243
  // from the previous hidden state when it patches props.
5249
5244
  craftNativeBoolean(props.opt("visible"))
5250
5245
  } else {
5251
- style.has("display") && style.optString("display") != "none"
5246
+ style.has("display") && craftNativeOptionalString(style.opt("display")) != "none"
5252
5247
  }
5253
5248
  val wasVisible = screen.modalVisibility[this]
5254
5249
  screen.modalVisibility[this] = visible
@@ -5480,7 +5475,7 @@ class MainActivity : AppCompatActivity() {
5480
5475
  drawing = node
5481
5476
  }
5482
5477
  "ScrollView", "SectionList" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
5483
- val horizontal = craftNativeBoolean(props.opt("horizontal")) || style.optString("flexDirection") in setOf("row", "row-reverse")
5478
+ val horizontal = craftNativeBoolean(props.opt("horizontal")) || craftNativeOptionalString(style.opt("flexDirection")) in setOf("row", "row-reverse")
5484
5479
  setHorizontal(horizontal)
5485
5480
  setNativeNestedScrollingEnabled(true)
5486
5481
  setScrollIndicators(
@@ -5488,7 +5483,7 @@ class MainActivity : AppCompatActivity() {
5488
5483
  craftNativeBoolean(props.opt("showsHorizontalScrollIndicator"), true),
5489
5484
  )
5490
5485
  setScrollEnabled(craftNativeBoolean(props.opt("scrollEnabled"), true))
5491
- setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
5486
+ setKeyboardShouldPersistTaps(craftNativeOptionalString(props.opt("keyboardShouldPersistTaps")) ?: "never")
5492
5487
  setBounces(craftNativeBoolean(props.opt("bounces"), true))
5493
5488
  setAlwaysBounce(
5494
5489
  craftNativeBoolean(props.opt("alwaysBounceVertical"), !horizontal),
@@ -5550,7 +5545,7 @@ class MainActivity : AppCompatActivity() {
5550
5545
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5551
5546
  }
5552
5547
  },
5553
- keyboardDismissMode = props.optString("keyboardDismissMode"),
5548
+ keyboardDismissMode = craftNativeOptionalString(props.opt("keyboardDismissMode")).orEmpty(),
5554
5549
  dismissKeyboard = {
5555
5550
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
5556
5551
  ?.hideSoftInputFromWindow(nativeScroll.windowToken, 0)
@@ -5587,9 +5582,9 @@ class MainActivity : AppCompatActivity() {
5587
5582
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5588
5583
  }, craftNativeBoolean(propRefresh.opt("refreshing"), craftNativeBoolean(props.opt("refreshing"))))
5589
5584
  setRefreshAppearance(propRefresh, refreshTintFallback(screen))
5590
- nativeList.setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
5585
+ nativeList.setKeyboardShouldPersistTaps(craftNativeOptionalString(props.opt("keyboardShouldPersistTaps")) ?: "never")
5591
5586
  nativeList.setContentContainerStyle(props.optJSONObject("contentContainerStyle"), resources.displayMetrics.density)
5592
- nativeList.setKeyboardDismissMode(props.optString("keyboardDismissMode")) {
5587
+ nativeList.setKeyboardDismissMode(craftNativeOptionalString(props.opt("keyboardDismissMode")).orEmpty()) {
5593
5588
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
5594
5589
  ?.hideSoftInputFromWindow(windowToken, 0)
5595
5590
  }
@@ -5598,6 +5593,7 @@ class MainActivity : AppCompatActivity() {
5598
5593
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
5599
5594
  configureContainer(this, style)
5600
5595
  if (type == "SafeAreaView") setSafeAreaEdges(props.optJSONArray("edges"))
5596
+ else setWindowSafeInsets(0, 0, 0, 0)
5601
5597
  reconcileChildren(screen, this, children, identity, next, style, previousControls)
5602
5598
  }
5603
5599
  "KeyboardAvoidingView" -> (previous as? CraftNativeKeyboardAvoidingView ?: CraftNativeKeyboardAvoidingView(this)).apply {
@@ -5744,7 +5740,7 @@ class MainActivity : AppCompatActivity() {
5744
5740
  if (type == "ActivityIndicator") {
5745
5741
  val animating = craftNativeBoolean(props.opt("animating"), true)
5746
5742
  val hidesWhenStopped = craftNativeBoolean(props.opt("hidesWhenStopped"), true)
5747
- control.visibility = if (style.optString("display") == "none" || (!animating && hidesWhenStopped)) {
5743
+ control.visibility = if (craftNativeOptionalString(style.opt("display")) == "none" || (!animating && hidesWhenStopped)) {
5748
5744
  View.GONE
5749
5745
  } else {
5750
5746
  View.VISIBLE
@@ -5865,7 +5861,7 @@ class MainActivity : AppCompatActivity() {
5865
5861
  layout.setChildStyle(childView, child.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
5866
5862
  }
5867
5863
  }
5868
- if (style.optString("flexDirection").endsWith("-reverse")) desired.reverse()
5864
+ if (craftNativeOptionalString(style.opt("flexDirection"))?.endsWith("-reverse") == true) desired.reverse()
5869
5865
  val desiredSet = desired.toSet()
5870
5866
  for ((index, childView) in desired.withIndex()) {
5871
5867
  if (layout.getChildAt(index) !== childView) {
@@ -5992,7 +5988,7 @@ class MainActivity : AppCompatActivity() {
5992
5988
  }
5993
5989
  applyTransform(view, style.optJSONArray("transform"))
5994
5990
  if (view !is CraftNativeModalView) {
5995
- view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
5991
+ view.visibility = if (craftNativeOptionalString(style.opt("display")) == "none") View.GONE else View.VISIBLE
5996
5992
  }
5997
5993
  view.elevation = dp(craftNativeNonNegativeDouble(style, "elevation", 0.0)).toFloat()
5998
5994
  view.z = craftNativeFiniteFloat(style, "zIndex", 0f)
@@ -6001,13 +5997,13 @@ class MainActivity : AppCompatActivity() {
6001
5997
  // background paints over the decoded picture (only rounded corners
6002
5998
  // showed it). Keep the outline/borders, with a transparent fill.
6003
5999
  val backgroundColor = if (view is CraftNativeVideoView) Color.TRANSPARENT
6004
- else color(style.optString("backgroundColor"), Color.TRANSPARENT)
6000
+ else color(craftNativeOptionalString(style.opt("backgroundColor")).orEmpty(), Color.TRANSPARENT)
6005
6001
  val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
6006
6002
  val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
6007
- val gradientDirection = style.optString("gradientDirection").takeIf { it.isNotBlank() }
6008
- val gradientFrom = style.optString("gradientFrom").takeIf { it.isNotBlank() }
6009
- val gradientVia = style.optString("gradientVia").takeIf { it.isNotBlank() }
6010
- val gradientTo = style.optString("gradientTo").takeIf { it.isNotBlank() }
6003
+ val gradientDirection = craftNativeOptionalString(style.opt("gradientDirection"))
6004
+ val gradientFrom = craftNativeOptionalString(style.opt("gradientFrom"))
6005
+ val gradientVia = craftNativeOptionalString(style.opt("gradientVia"))
6006
+ val gradientTo = craftNativeOptionalString(style.opt("gradientTo"))
6011
6007
  val gradientColors = if (gradientDirection != null && gradientFrom != null && gradientTo != null) {
6012
6008
  listOfNotNull(
6013
6009
  color(gradientFrom, Color.TRANSPARENT),
@@ -6025,7 +6021,7 @@ class MainActivity : AppCompatActivity() {
6025
6021
  listOf(radius, radius)
6026
6022
  }.toFloatArray()
6027
6023
  val sideColors = listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")
6028
- .map { color(style.optString(it).ifBlank { style.optString("borderColor") }, Color.TRANSPARENT) }
6024
+ .map { color(craftNativeOptionalString(style.opt(it)) ?: craftNativeOptionalString(style.opt("borderColor")).orEmpty(), Color.TRANSPARENT) }
6029
6025
  .toIntArray()
6030
6026
  val defaultBackground = if (defaultBackgrounds.containsKey(view)) {
6031
6027
  defaultBackgrounds[view]
@@ -6039,7 +6035,7 @@ class MainActivity : AppCompatActivity() {
6039
6035
  cornerRadii,
6040
6036
  sideWidths,
6041
6037
  sideColors,
6042
- style.optString("borderStyle").takeIf { it == "dashed" || it == "dotted" },
6038
+ craftNativeOptionalString(style.opt("borderStyle"))?.takeIf { it == "dashed" || it == "dotted" },
6043
6039
  gradientDirection,
6044
6040
  gradientColors,
6045
6041
  )
@@ -6051,8 +6047,8 @@ class MainActivity : AppCompatActivity() {
6051
6047
  } else setColor(backgroundColor)
6052
6048
  if (hasCornerRadii) setCornerRadii(cornerRadii) else cornerRadius = dp(radius).toFloat()
6053
6049
  if (border > 0) {
6054
- val borderColor = color(style.optString("borderColor"), Color.TRANSPARENT)
6055
- when (style.optString("borderStyle")) {
6050
+ val borderColor = color(craftNativeOptionalString(style.opt("borderColor")).orEmpty(), Color.TRANSPARENT)
6051
+ when (craftNativeOptionalString(style.opt("borderStyle"))) {
6056
6052
  "dashed" -> setStroke(dp(border), borderColor, dp(border * 3).toFloat(), dp(border * 2).toFloat())
6057
6053
  "dotted" -> setStroke(dp(border), borderColor, dp(border).toFloat(), dp(border * 1.5).toFloat())
6058
6054
  else -> setStroke(dp(border), borderColor)
@@ -6065,13 +6061,14 @@ class MainActivity : AppCompatActivity() {
6065
6061
  // and the web.
6066
6062
  null
6067
6063
  } else defaultBackground
6068
- view.clipToOutline = style.optString("overflow") == "hidden"
6069
- if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
6064
+ val overflow = craftNativeOptionalString(style.opt("overflow"))
6065
+ view.clipToOutline = overflow == "hidden"
6066
+ if (view is ViewGroup) view.clipChildren = overflow == "hidden"
6070
6067
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
6071
6068
  (style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
6072
6069
  ) {
6073
6070
  val shadowOpacity = craftNativeFiniteFloat(style, "shadowOpacity", 1f).coerceIn(0f, 1f)
6074
- val shadow = color(style.optString("shadowColor"), Color.BLACK)
6071
+ val shadow = color(craftNativeOptionalString(style.opt("shadowColor")).orEmpty(), Color.BLACK)
6075
6072
  val alphaShadow = Color.argb((Color.alpha(shadow) * shadowOpacity).roundToInt(), Color.red(shadow), Color.green(shadow), Color.blue(shadow))
6076
6073
  view.outlineAmbientShadowColor = alphaShadow
6077
6074
  view.outlineSpotShadowColor = alphaShadow
@@ -6169,7 +6166,7 @@ class MainActivity : AppCompatActivity() {
6169
6166
  }
6170
6167
 
6171
6168
  private fun configureText(view: TextView, value: String, style: JSONObject) {
6172
- view.text = when (style.optString("textTransform")) {
6169
+ view.text = when (craftNativeOptionalString(style.opt("textTransform"))) {
6173
6170
  "uppercase" -> value.uppercase()
6174
6171
  "lowercase" -> value.lowercase()
6175
6172
  "capitalize" -> value.split(' ').joinToString(" ") { word -> word.replaceFirstChar(Char::uppercase) }
@@ -6279,21 +6276,22 @@ class MainActivity : AppCompatActivity() {
6279
6276
  }
6280
6277
 
6281
6278
  private fun configureTextStyle(view: TextView, style: JSONObject) {
6282
- view.setTextColor(color(style.optString("color"), defaultTextColor))
6279
+ view.setTextColor(color(craftNativeOptionalString(style.opt("color")).orEmpty(), defaultTextColor))
6283
6280
  view.setTextSize(TypedValue.COMPLEX_UNIT_SP, craftNativeFiniteFloat(style, "fontSize", 16f))
6284
- val fontWeight = style.opt("fontWeight") as? String
6281
+ val fontWeight = craftNativeFontWeight(style.opt("fontWeight"))
6285
6282
  val weight = when (fontWeight) {
6286
6283
  "bold" -> 700
6287
6284
  "100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
6288
6285
  else -> 400
6289
6286
  }
6290
- val italic = style.optString("fontStyle") == "italic"
6291
- val family = style.optString("fontFamily").takeIf { it.isNotBlank() }
6287
+ val italic = craftNativeOptionalString(style.opt("fontStyle")) == "italic"
6288
+ val family = craftNativeOptionalString(style.opt("fontFamily"))?.takeIf { it.isNotBlank() }
6292
6289
  view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
6293
6290
  Typeface.create(Typeface.create(family, Typeface.NORMAL), weight, italic)
6294
6291
  } else Typeface.create(family, (if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) or
6295
6292
  (if (italic) Typeface.ITALIC else Typeface.NORMAL))
6296
- view.textAlignment = when (style.optString("textAlign")) {
6293
+ val textAlign = craftNativeOptionalString(style.opt("textAlign"))
6294
+ view.textAlignment = when (textAlign) {
6297
6295
  "left" -> View.TEXT_ALIGNMENT_TEXT_START
6298
6296
  "right" -> View.TEXT_ALIGNMENT_TEXT_END
6299
6297
  "center" -> View.TEXT_ALIGNMENT_CENTER
@@ -6301,7 +6299,7 @@ class MainActivity : AppCompatActivity() {
6301
6299
  else -> View.TEXT_ALIGNMENT_INHERIT
6302
6300
  }
6303
6301
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
6304
- view.justificationMode = if (style.optString("textAlign") == "justify") {
6302
+ view.justificationMode = if (textAlign == "justify") {
6305
6303
  Layout.JUSTIFICATION_MODE_INTER_WORD
6306
6304
  } else {
6307
6305
  Layout.JUSTIFICATION_MODE_NONE
@@ -6315,7 +6313,7 @@ class MainActivity : AppCompatActivity() {
6315
6313
  craftNativeFiniteDouble(style, "lineHeight", Double.NaN).takeIf { it.isFinite() }?.let { dp(it) - view.textSize } ?: 0f,
6316
6314
  1f
6317
6315
  )
6318
- val shadowColor = style.optString("textShadowColor").takeIf { it.isNotBlank() }
6316
+ val shadowColor = craftNativeOptionalString(style.opt("textShadowColor"))?.takeIf { it.isNotBlank() }
6319
6317
  if (shadowColor == null) {
6320
6318
  view.paint.clearShadowLayer()
6321
6319
  } else {
@@ -6332,16 +6330,16 @@ class MainActivity : AppCompatActivity() {
6332
6330
  }
6333
6331
 
6334
6332
  private fun applyTextDecoration(view: TextView, style: JSONObject) {
6335
- val line = style.optString("textDecorationLine")
6333
+ val line = craftNativeOptionalString(style.opt("textDecorationLine")).orEmpty()
6336
6334
  if (view is EditText) {
6337
6335
  // A field's own text, spanned in place: replacing it would move
6338
6336
  // the caret to the start and tell the screen the text changed.
6339
6337
  val editable = view.text ?: return
6340
6338
  editable.getSpans(0, editable.length, CraftTextDecorationSpan::class.java).forEach(editable::removeSpan)
6341
6339
  if (line.isBlank() || line == "none" || editable.isEmpty()) return
6342
- val fieldColor = style.optString("textDecorationColor").takeIf { it.isNotBlank() }?.let { color(it, Color.TRANSPARENT) }
6340
+ val fieldColor = craftNativeOptionalString(style.opt("textDecorationColor"))?.takeIf { it.isNotBlank() }?.let { color(it, Color.TRANSPARENT) }
6343
6341
  editable.setSpan(
6344
- CraftTextDecorationSpan(line, style.optString("textDecorationStyle").ifBlank { "solid" }, fieldColor),
6342
+ CraftTextDecorationSpan(line, craftNativeOptionalString(style.opt("textDecorationStyle")).orEmpty().ifBlank { "solid" }, fieldColor),
6345
6343
  0,
6346
6344
  editable.length,
6347
6345
  Spannable.SPAN_INCLUSIVE_INCLUSIVE,
@@ -6353,14 +6351,14 @@ class MainActivity : AppCompatActivity() {
6353
6351
  if (view.text is Spannable) view.text = text
6354
6352
  return
6355
6353
  }
6356
- val decorationColor = style.optString("textDecorationColor")
6354
+ val decorationColor = craftNativeOptionalString(style.opt("textDecorationColor")).orEmpty()
6357
6355
  .takeIf { it.isNotBlank() }
6358
6356
  ?.let { color(it, Color.TRANSPARENT) }
6359
6357
  val decorated = SpannableString(text)
6360
6358
  decorated.setSpan(
6361
6359
  CraftTextDecorationSpan(
6362
6360
  line,
6363
- style.optString("textDecorationStyle").ifBlank { "solid" },
6361
+ craftNativeOptionalString(style.opt("textDecorationStyle")).orEmpty().ifBlank { "solid" },
6364
6362
  decorationColor,
6365
6363
  ),
6366
6364
  0,
@@ -6568,8 +6566,16 @@ class MainActivity : AppCompatActivity() {
6568
6566
  info.roleDescription = role.takeIf { it !in setOf("view", "text", "textinput", "scrollview") }
6569
6567
  info.hintText = host.tooltipText
6570
6568
  info.isEnabled = host.isEnabled && !disabled
6571
- info.isSelected = craftNativeBoolean(state?.opt("selected"))
6572
- info.isChecked = craftNativeBoolean(state?.opt("checked"))
6569
+ // Keep the native widget state when no accessibilityState was
6570
+ // authored. Overwriting it with the strict false fallback
6571
+ // made a checked Switch/CheckBox announce as unchecked unless
6572
+ // the screen redundantly mirrored its value into metadata.
6573
+ if (state?.has("selected") == true) {
6574
+ info.isSelected = craftNativeBoolean(state.opt("selected"))
6575
+ }
6576
+ if (state?.has("checked") == true) {
6577
+ info.isChecked = craftNativeBoolean(state.opt("checked"))
6578
+ }
6573
6579
  }
6574
6580
  })
6575
6581
  ViewCompat.setImportantForAccessibility(