@craft-native/android 0.0.173 → 0.0.175

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,9 +1,11 @@
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
@@ -86,6 +88,17 @@ import androidx.javascriptengine.Message
86
88
  import androidx.javascriptengine.MessagePort
87
89
  import androidx.javascriptengine.MessagePortClient
88
90
  import com.craft.runtime.CraftNative
91
+ import com.google.android.gms.maps.CameraUpdateFactory
92
+ import com.google.android.gms.maps.GoogleMap
93
+ import com.google.android.gms.maps.MapView
94
+ import com.google.android.gms.maps.OnMapReadyCallback
95
+ import com.google.android.gms.maps.model.BitmapDescriptorFactory
96
+ import com.google.android.gms.maps.model.LatLng
97
+ import com.google.android.gms.maps.model.LatLngBounds
98
+ import com.google.android.gms.maps.model.Marker
99
+ import com.google.android.gms.maps.model.MarkerOptions
100
+ import com.google.android.gms.maps.model.Polyline
101
+ import com.google.android.gms.maps.model.PolylineOptions
89
102
  import org.json.JSONArray
90
103
  import org.json.JSONObject
91
104
  import java.net.URL
@@ -459,6 +472,156 @@ private class CraftNativeSvgView(context: Context) : View(context) {
459
472
  private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4)
460
473
  }
461
474
 
475
+ /** The Android host for the small, host-neutral native trail map contract. */
476
+ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
477
+ private val mapView = MapView(context)
478
+ private var googleMap: GoogleMap? = null
479
+ private var pendingProps = JSONObject()
480
+ private var pendingChildren = JSONArray()
481
+ private val markers = mutableMapOf<String, Marker>()
482
+ private val polylines = mutableMapOf<String, Polyline>()
483
+ private val markerHandlers = mutableMapOf<String, String>()
484
+ private var onMarkerTap: ((String) -> Unit)? = null
485
+
486
+ init {
487
+ addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
488
+ mapView.onCreate(null)
489
+ mapView.getMapAsync(this)
490
+ }
491
+
492
+ override fun onMapReady(map: GoogleMap) {
493
+ googleMap = map
494
+ map.setOnMarkerClickListener { marker ->
495
+ val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
496
+ onMarkerTap?.invoke(id)
497
+ false
498
+ }
499
+ applyPending()
500
+ }
501
+
502
+ fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
503
+ pendingProps = props
504
+ pendingChildren = children
505
+ onMarkerTap = markerHandler
506
+ applyPending()
507
+ }
508
+
509
+ fun markerHandler(id: String): String? = markerHandlers[id]
510
+
511
+ private fun applyPending() {
512
+ val map = googleMap ?: return
513
+ map.uiSettings.isScrollGesturesEnabled = pendingProps.optBoolean("scrollEnabled", true)
514
+ map.uiSettings.isZoomGesturesEnabled = pendingProps.optBoolean("zoomEnabled", true)
515
+ map.uiSettings.isRotateGesturesEnabled = pendingProps.optBoolean("rotateEnabled", false)
516
+ map.mapType = when (pendingProps.optString("mapType").lowercase()) {
517
+ "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
518
+ "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
519
+ "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
520
+ else -> GoogleMap.MAP_TYPE_NORMAL
521
+ }
522
+ val showUserLocation = pendingProps.optBoolean("showsUserLocation", false) && hasLocationPermission()
523
+ runCatching { map.isMyLocationEnabled = showUserLocation }
524
+ val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
525
+ val nextHandlers = mutableMapOf<String, String>()
526
+ val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
527
+ for (index in 0 until pendingChildren.length()) {
528
+ val child = pendingChildren.optJSONObject(index) ?: continue
529
+ val type = child.optString("type").lowercase()
530
+ val props = child.optJSONObject("props") ?: JSONObject()
531
+ val events = child.optJSONObject("events") ?: JSONObject()
532
+ val id = props.opt("id")?.takeUnless { it == JSONObject.NULL }?.toString()
533
+ ?: child.optString("id").takeIf { it.isNotBlank() }
534
+ ?: "overlay-$index"
535
+ val points = coordinates(props.optJSONArray("geometry"))
536
+ if (type == "mapmarker" || type == "marker") {
537
+ points.firstOrNull()?.let { point ->
538
+ nextMarkers[id] = point to props.optString("title").takeIf { it.isNotBlank() }
539
+ events.optString("onPress").ifBlank { events.optString("onMarkerPress") }
540
+ .takeIf { it.isNotBlank() }?.let { nextHandlers[id] = it }
541
+ }
542
+ } else if (type == "mappolyline" || type == "polyline") {
543
+ if (points.size > 1) {
544
+ val width = props.optDouble("strokeWidth", 3.0).toFloat().takeIf { it.isFinite() && it > 0 } ?: 3f
545
+ nextPolylines[id] = Triple(points, color(props.optString("strokeColor")), width)
546
+ }
547
+ }
548
+ }
549
+ markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
550
+ for ((id, value) in nextMarkers) {
551
+ val marker = markers[id]
552
+ if (marker != null) {
553
+ marker.position = value.first
554
+ marker.title = value.second
555
+ } else {
556
+ map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(BitmapDescriptorFactory.defaultMarker()))?.let { markers[id] = it }
557
+ }
558
+ }
559
+ markerHandlers.clear()
560
+ markerHandlers.putAll(nextHandlers)
561
+ polylines.values.forEach { it.remove() }
562
+ polylines.clear()
563
+ for ((id, value) in nextPolylines) {
564
+ polylines[id] = map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))
565
+ }
566
+ if (pendingProps.optBoolean("fitToBounds", false)) fitToBounds()
567
+ region(pendingProps.optJSONObject("region") ?: pendingProps.optJSONObject("initialRegion"))?.let { bounds ->
568
+ post {
569
+ if (width > 0 && height > 0) map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 24))
570
+ }
571
+ }
572
+ }
573
+
574
+ private fun fitToBounds() {
575
+ val map = googleMap ?: return
576
+ val builder = LatLngBounds.Builder()
577
+ var count = 0
578
+ markers.values.forEach { builder.include(it.position); count++ }
579
+ polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
580
+ if (count == 0) return
581
+ post {
582
+ if (width > 0 && height > 0) map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 24))
583
+ }
584
+ }
585
+
586
+ override fun onAttachedToWindow() {
587
+ super.onAttachedToWindow()
588
+ mapView.onStart()
589
+ mapView.onResume()
590
+ }
591
+
592
+ override fun onDetachedFromWindow() {
593
+ mapView.onPause()
594
+ mapView.onStop()
595
+ super.onDetachedFromWindow()
596
+ }
597
+
598
+ private fun hasLocationPermission(): Boolean =
599
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
600
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
601
+
602
+ private fun coordinates(geometry: JSONArray?): List<LatLng> {
603
+ if (geometry == null) return emptyList()
604
+ return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
605
+ val latitude = geometry.optDouble(index, Double.NaN)
606
+ val longitude = geometry.optDouble(index + 1, Double.NaN)
607
+ if (latitude.isFinite() && longitude.isFinite()) LatLng(latitude, longitude) else null
608
+ }
609
+ }
610
+
611
+ private fun region(value: JSONObject?): LatLngBounds? {
612
+ val center = value?.optJSONObject("center") ?: return null
613
+ val span = value.optJSONObject("span") ?: return null
614
+ val latitude = center.optDouble("latitude", Double.NaN)
615
+ val longitude = center.optDouble("longitude", Double.NaN)
616
+ val latitudeDelta = span.optDouble("latitudeDelta", span.optDouble("latitude_delta", Double.NaN))
617
+ val longitudeDelta = span.optDouble("longitudeDelta", span.optDouble("longitude_delta", Double.NaN))
618
+ if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite()) return null
619
+ return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
620
+ }
621
+
622
+ private fun color(value: String): Int = runCatching { Color.parseColor(value) }.getOrDefault(Color.BLUE)
623
+ }
624
+
462
625
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
463
626
  private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
464
627
  var onLayoutChanged: (() -> Unit)? = null
@@ -474,6 +637,46 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
474
637
  private var gridAutoRows: CraftNativeLength? = null
475
638
  private var reverse = false
476
639
  private var wrapReverse = false
640
+ private var authoredPadding = intArrayOf(0, 0, 0, 0)
641
+ private var safeAreaPadding = intArrayOf(0, 0, 0, 0)
642
+ private var applyingPadding = false
643
+ private var safeAreaEdges = setOf("top", "right", "bottom", "left")
644
+
645
+ override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
646
+ if (!applyingPadding) authoredPadding = intArrayOf(left, top, right, bottom)
647
+ applyingPadding = true
648
+ super.setPadding(
649
+ left + safeAreaPadding[0],
650
+ top + safeAreaPadding[1],
651
+ right + safeAreaPadding[2],
652
+ bottom + safeAreaPadding[3],
653
+ )
654
+ applyingPadding = false
655
+ }
656
+
657
+ fun setSafeAreaEdges(raw: JSONArray?) {
658
+ safeAreaEdges = if (raw == null) {
659
+ setOf("top", "right", "bottom", "left")
660
+ } else {
661
+ (0 until raw.length()).mapNotNull { raw.optString(it).takeIf { edge -> edge in setOf("top", "right", "bottom", "left") } }.toSet()
662
+ }
663
+ applySafeAreaPadding()
664
+ }
665
+
666
+ fun setWindowSafeInsets(left: Int, top: Int, right: Int, bottom: Int) {
667
+ val insets = intArrayOf(left, top, right, bottom)
668
+ safeAreaPadding = intArrayOf(
669
+ if ("left" in safeAreaEdges) insets[0] else 0,
670
+ if ("top" in safeAreaEdges) insets[1] else 0,
671
+ if ("right" in safeAreaEdges) insets[2] else 0,
672
+ if ("bottom" in safeAreaEdges) insets[3] else 0,
673
+ )
674
+ applySafeAreaPadding()
675
+ }
676
+
677
+ private fun applySafeAreaPadding() {
678
+ setPadding(authoredPadding[0], authoredPadding[1], authoredPadding[2], authoredPadding[3])
679
+ }
477
680
 
478
681
  /**
479
682
  * What a child measures at a pair of specs, remembered for the frame.
@@ -643,7 +846,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
643
846
  if (index > 0 && lineIds != null && lineIds[index] != lineIds[index - 1]) {
644
847
  lines += line
645
848
  line = Line()
646
- } else if (index > 0 && lineIds == null && wrap && line.entries.isNotEmpty() && line.main + gap + childMain > availableMain) {
849
+ } else if (index > 0 && lineIds == null && wrap && line.entries.isNotEmpty() && line.main + gap + childMain > availableMain + 0.5f * (line.entries.size + 1)) {
850
+ // Each size is rounded to whole pixels, up to half a pixel
851
+ // over: two 60-point items fill a 120-point row exactly
852
+ // in points, and at 2.625 pixels per point (157.5 + 157.5
853
+ // in 315) rounding alone must not wrap the second.
647
854
  lines += line
648
855
  line = Line()
649
856
  }
@@ -1343,6 +1550,18 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1343
1550
  if (handler == null) isRefreshing = false
1344
1551
  }
1345
1552
 
1553
+ fun setRefreshAppearance(props: JSONObject, density: Float) {
1554
+ val tint = props.optString("tintColor").takeIf { it.isNotBlank() }
1555
+ if (tint != null) setColorSchemeColors(craftNativeColor(tint))
1556
+ else setColorSchemeColors(Color.WHITE)
1557
+ val background = props.optString("progressBackgroundColor").takeIf { it.isNotBlank() }
1558
+ if (background != null) setProgressBackgroundColorSchemeColor(craftNativeColor(background))
1559
+ else setProgressBackgroundColorSchemeColor(Color.TRANSPARENT)
1560
+ val offset = props.optDouble("progressViewOffset", Double.NaN)
1561
+ if (offset.isFinite()) setProgressViewOffset(true, 0, (offset * density).roundToInt())
1562
+ else setProgressViewOffset(false, 0, 0)
1563
+ }
1564
+
1346
1565
  override fun onDetachedFromWindow() {
1347
1566
  refreshHandler = null
1348
1567
  isRefreshing = false
@@ -1783,12 +2002,15 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1783
2002
 
1784
2003
  private fun changed() {
1785
2004
  if (suppressChanges) return
1786
- val picker = timePicker ?: return
1787
- val snapped = ((picker.minute + minuteInterval / 2) / minuteInterval * minuteInterval).coerceAtMost(59)
1788
- if (snapped != picker.minute) {
1789
- suppressChanges = true
1790
- picker.minute = snapped
1791
- suppressChanges = false
2005
+ // A date-only picker has no time half to snap, but its change is
2006
+ // still the screen's to hear.
2007
+ timePicker?.let { picker ->
2008
+ val snapped = ((picker.minute + minuteInterval / 2) / minuteInterval * minuteInterval).coerceAtMost(59)
2009
+ if (snapped != picker.minute) {
2010
+ suppressChanges = true
2011
+ picker.minute = snapped
2012
+ suppressChanges = false
2013
+ }
1792
2014
  }
1793
2015
  onValueChanged?.invoke()
1794
2016
  }
@@ -2182,6 +2404,9 @@ class MainActivity : AppCompatActivity() {
2182
2404
  ) {
2183
2405
  /** What the screen set with `craft.navigation.setOptions`, merged. */
2184
2406
  var options = JSONObject()
2407
+ /** Overrides from a native `<StatusBar>` node, reset on each render. */
2408
+ var statusBarStyle: String? = null
2409
+ var statusBarHidden: Boolean? = null
2185
2410
  /** Shown before: its next showing is a `SCREEN_FOCUS`. */
2186
2411
  var shown = false
2187
2412
  /** The app path it was opened for (`/m/workout/42`), to find it again. */
@@ -2209,6 +2434,7 @@ class MainActivity : AppCompatActivity() {
2209
2434
  val nodeViews = mutableMapOf<String, View>()
2210
2435
  val nodePaths = mutableMapOf<String, String>()
2211
2436
  val handlers = mutableMapOf<View, String>()
2437
+ val linkTargets = mutableMapOf<View, String>()
2212
2438
  val pressInHandlers = mutableMapOf<View, String>()
2213
2439
  val pressOutHandlers = mutableMapOf<View, String>()
2214
2440
  val longPressHandlers = mutableMapOf<View, String>()
@@ -2279,6 +2505,9 @@ class MainActivity : AppCompatActivity() {
2279
2505
  private val tabStacks = mutableListOf<MutableList<Screen>>()
2280
2506
  private var currentTab = 0
2281
2507
  private var visibleScreen: Screen? = null
2508
+ private var tabSwipeTarget: Screen? = null
2509
+ private var tabSwipeTargetIndex = -1
2510
+ private var tabSwipeDirection = 0
2282
2511
  /** The app script's isolate (`nativeApp`), when the app has one. */
2283
2512
  private var appScreen: Screen? = null
2284
2513
  private val urlSchemes = mutableSetOf<String>()
@@ -2298,6 +2527,7 @@ class MainActivity : AppCompatActivity() {
2298
2527
  private var initialDeepLink: Uri? = null
2299
2528
  private var initialDeepLinkClaimed = false
2300
2529
  private var defaultTextColor = Color.WHITE
2530
+ private var windowSafeInsets = intArrayOf(0, 0, 0, 0)
2301
2531
  private var useMessagePorts = false
2302
2532
  private val imageExecutor = Executors.newFixedThreadPool(2)
2303
2533
  /** Lays out each isolate's prelude (the synchronous stores) off the main thread. */
@@ -2330,8 +2560,9 @@ class MainActivity : AppCompatActivity() {
2330
2560
  ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
2331
2561
  val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
2332
2562
  val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
2563
+ windowSafeInsets = intArrayOf(safe.left, safe.top, safe.right, safe.bottom)
2333
2564
  keyboardInset = max(0, ime.bottom - safe.bottom)
2334
- view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
2565
+ updateSafeAreaViews()
2335
2566
  updateKeyboardAvoidingViews()
2336
2567
  view.requestLayout()
2337
2568
  view.post { screens.lastOrNull()?.takeIf { it.active }?.let(::emitLayoutEvents) }
@@ -2443,6 +2674,9 @@ class MainActivity : AppCompatActivity() {
2443
2674
  haptic()
2444
2675
  if (index == currentTab) popToRoot() else selectTab(index)
2445
2676
  }
2677
+ bars.onTabSwipeStart = { dx, x, y -> beginTabSwipe(dx, x, y) }
2678
+ bars.onTabSwipeMove = { dx -> updateTabSwipe(dx) }
2679
+ bars.onTabSwipeEnd = { dx, cancelled -> finishTabSwipe(dx, cancelled) }
2446
2680
  root.addView(bars)
2447
2681
  chrome = bars
2448
2682
  tabStacks.clear()
@@ -2466,12 +2700,97 @@ class MainActivity : AppCompatActivity() {
2466
2700
  currentTab = index
2467
2701
  screens = tabStacks[index]
2468
2702
  chrome?.selectTab(index)
2469
- if (screens.isEmpty()) {
2470
- val match = routes.tabs.getOrNull(index)?.let { routes.match(it.id) }
2471
- if (match != null) openScreen(match.screen, match.params)?.path = match.path else openScreen(null)
2472
- } else {
2473
- show(screens.last())
2703
+ ensureTabRoot(index)?.let(::show)
2704
+ }
2705
+
2706
+ /** Mount a tab's root without stealing focus, for an interactive swipe. */
2707
+ private fun ensureTabRoot(index: Int): Screen? {
2708
+ if (index !in tabStacks.indices) return null
2709
+ tabStacks[index].lastOrNull()?.let { return it }
2710
+ val previous = screens
2711
+ screens = tabStacks[index]
2712
+ val match = routes.tabs.getOrNull(index)?.let { routes.match(it.id) }
2713
+ val screen = if (match != null) openScreen(match.screen, match.params, showImmediately = false) else openScreen(null, showImmediately = false)
2714
+ screen?.path = match?.path
2715
+ screens = previous
2716
+ return screen
2717
+ }
2718
+
2719
+ /** Start an interactive horizontal transition to the adjacent tab. */
2720
+ private fun beginTabSwipe(dx: Float, x: Float, y: Float): Boolean {
2721
+ if (tabSwipeTarget != null || screens.size != 1 || chrome?.tabSwipeEnabled != true) return false
2722
+ val direction = if (dx < 0) 1 else -1
2723
+ val targetIndex = currentTab + direction
2724
+ val current = visibleScreen ?: return false
2725
+ val target = ensureTabRoot(targetIndex) ?: return false
2726
+ val host = chrome?.content ?: return false
2727
+ if (target === current || host.width <= 0) return false
2728
+ if (horizontalScrollCanConsume(current.view, host, x, y, dx)) return false
2729
+ (target.view.parent as? ViewGroup)?.removeView(target.view)
2730
+ host.addView(target.view, FrameLayout.LayoutParams(-1, -1))
2731
+ target.view.translationX = if (direction > 0) host.width.toFloat() else -host.width.toFloat()
2732
+ current.view.translationX = 0f
2733
+ host.bringChildToFront(target.view)
2734
+ tabSwipeTarget = target
2735
+ tabSwipeTargetIndex = targetIndex
2736
+ tabSwipeDirection = direction
2737
+ chrome?.selectTab(targetIndex)
2738
+ return true
2739
+ }
2740
+
2741
+ private fun updateTabSwipe(dx: Float) {
2742
+ val current = visibleScreen ?: return
2743
+ val target = tabSwipeTarget ?: return
2744
+ val width = (chrome?.content?.width ?: return).toFloat().coerceAtLeast(1f)
2745
+ val offset = dx.coerceIn(-width, width)
2746
+ target.view.translationX = (if (tabSwipeDirection > 0) width else -width) + offset
2747
+ current.view.translationX = offset
2748
+ }
2749
+
2750
+ private fun finishTabSwipe(dx: Float, cancelled: Boolean) {
2751
+ val current = visibleScreen ?: return
2752
+ val target = tabSwipeTarget ?: return
2753
+ val host = chrome?.content ?: return
2754
+ val width = host.width.toFloat().coerceAtLeast(1f)
2755
+ val complete = !cancelled && kotlin.math.abs(dx) >= width * 0.28f
2756
+ val endOffset = if (complete) {
2757
+ if (tabSwipeDirection > 0) -width else width
2758
+ } else 0f
2759
+ val targetOffset = if (complete) 0f else if (tabSwipeDirection > 0) width else -width
2760
+ current.view.animate().translationX(endOffset).setDuration(180).start()
2761
+ target.view.animate().translationX(targetOffset).setDuration(180).withEndAction {
2762
+ target.view.animate().setListener(null)
2763
+ if (complete) {
2764
+ current.view.translationX = 0f
2765
+ currentTab = tabSwipeTargetIndex
2766
+ screens = tabStacks[currentTab]
2767
+ tabSwipeTarget = null
2768
+ tabSwipeTargetIndex = -1
2769
+ tabSwipeDirection = 0
2770
+ haptic()
2771
+ show(target)
2772
+ } else {
2773
+ host.removeView(target.view)
2774
+ current.view.translationX = 0f
2775
+ chrome?.selectTab(currentTab)
2776
+ tabSwipeTarget = null
2777
+ tabSwipeTargetIndex = -1
2778
+ tabSwipeDirection = 0
2779
+ }
2780
+ }.start()
2781
+ }
2782
+
2783
+ private fun horizontalScrollCanConsume(view: View, host: ViewGroup, x: Float, y: Float, dx: Float): Boolean {
2784
+ val localX = x - view.left
2785
+ val localY = y - host.top - view.top
2786
+ if (view !is ViewGroup || localX < 0 || localY < 0 || localX > view.width || localY > view.height) return false
2787
+ for (index in view.childCount - 1 downTo 0) {
2788
+ val child = view.getChildAt(index)
2789
+ if (!child.isShown || localX < child.left || localX > child.right || localY < child.top || localY > child.bottom) continue
2790
+ if (child is android.widget.HorizontalScrollView && child.canScrollHorizontally(if (dx < 0) 1 else -1)) return true
2791
+ if (horizontalScrollCanConsume(child, host, localX - child.left, localY - child.top, dx)) return true
2474
2792
  }
2793
+ return false
2475
2794
  }
2476
2795
 
2477
2796
  /**
@@ -2523,7 +2842,7 @@ class MainActivity : AppCompatActivity() {
2523
2842
  services.perform("Browser", "open", JSONArray().put(url), {})
2524
2843
  }
2525
2844
 
2526
- private fun openScreen(route: String?, params: JSONObject = JSONObject(), headless: Boolean = false): Screen? {
2845
+ private fun openScreen(route: String?, params: JSONObject = JSONObject(), headless: Boolean = false, showImmediately: Boolean = true): Screen? {
2527
2846
  val engine = sandbox ?: return null
2528
2847
  val isolate = engine.createIsolate()
2529
2848
  val view = CraftNativeFlexLayout(this).apply {
@@ -2560,7 +2879,7 @@ class MainActivity : AppCompatActivity() {
2560
2879
  }
2561
2880
  if (!headless) {
2562
2881
  screens.add(activeScreen)
2563
- show(activeScreen)
2882
+ if (showImmediately) show(activeScreen)
2564
2883
  }
2565
2884
  val routePrelude = """
2566
2885
  globalThis.__stxNativeRoute = ${route?.let { JSONObject.quote(it) } ?: "undefined"};
@@ -2844,6 +3163,23 @@ class MainActivity : AppCompatActivity() {
2844
3163
  else -> null
2845
3164
  }
2846
3165
  }.forEach { it.refreshThemeDefaults() }
3166
+ updateSafeAreaViews()
3167
+ }
3168
+
3169
+ private fun updateSafeAreaViews() {
3170
+ if (!::root.isInitialized) return
3171
+ val active = visibleScreen ?: screens.lastOrNull { it.active }
3172
+ val rootNode = active?.mutations?.node("root")
3173
+ val safeRoot = rootNode?.let { canonicalType(it.optString("type")) == "SafeAreaView" } == true
3174
+ root.setPadding(
3175
+ if (safeRoot) 0 else windowSafeInsets[0],
3176
+ if (safeRoot) 0 else windowSafeInsets[1],
3177
+ if (safeRoot) 0 else windowSafeInsets[2],
3178
+ if (safeRoot) 0 else windowSafeInsets[3],
3179
+ )
3180
+ val tree = active?.view?.getChildAt(0) as? CraftNativeFlexLayout
3181
+ if (safeRoot) tree?.setWindowSafeInsets(windowSafeInsets[0], windowSafeInsets[1], windowSafeInsets[2], windowSafeInsets[3])
3182
+ else tree?.setWindowSafeInsets(0, 0, 0, 0)
2847
3183
  }
2848
3184
 
2849
3185
  private fun refreshThemeDefaults() {
@@ -3061,6 +3397,8 @@ class MainActivity : AppCompatActivity() {
3061
3397
 
3062
3398
  private fun renderCommitted(screen: Screen, document: JSONObject) {
3063
3399
  // Keep live controls (especially the focused EditText) across renders.
3400
+ screen.statusBarStyle = null
3401
+ screen.statusBarHidden = null
3064
3402
  val next = mutableMapOf<String, View>()
3065
3403
  screen.nodeViews.clear()
3066
3404
  screen.nodePaths.clear()
@@ -3075,10 +3413,24 @@ class MainActivity : AppCompatActivity() {
3075
3413
  screen.controls.putAll(next)
3076
3414
  screen.handlers.keys.retainAll(retainedViews(screen))
3077
3415
  screen.view.setChildStyle(tree, document.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
3416
+ updateSafeAreaViews()
3078
3417
  // A full render can change a view's custom layout style without
3079
3418
  // changing the root bounds. Ensure native @layout observers still see
3080
3419
  // the settled child frame after the next traversal.
3081
3420
  scheduleLayoutEvents(screen)
3421
+ if (visibleScreen === screen) applyStatusBar(screen)
3422
+ }
3423
+
3424
+ private fun applyStatusBar(screen: Screen) {
3425
+ val controller = WindowCompat.getInsetsController(window, window.decorView)
3426
+ val night = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
3427
+ controller.isAppearanceLightStatusBars = when (screen.statusBarStyle?.lowercase()) {
3428
+ "light-content", "light" -> false
3429
+ "dark-content", "dark" -> true
3430
+ else -> !night
3431
+ }
3432
+ if (screen.statusBarHidden == true) controller.hide(WindowInsetsCompat.Type.statusBars())
3433
+ else controller.show(WindowInsetsCompat.Type.statusBars())
3082
3434
  }
3083
3435
 
3084
3436
  private fun clearRenderedTree(screen: Screen) {
@@ -3088,6 +3440,7 @@ class MainActivity : AppCompatActivity() {
3088
3440
  screen.nodeViews.clear()
3089
3441
  screen.nodePaths.clear()
3090
3442
  screen.handlers.clear()
3443
+ screen.linkTargets.clear()
3091
3444
  screen.flatListRows.clear()
3092
3445
  screen.flatListOwners.clear()
3093
3446
  screen.flatListOwnerByView.clear()
@@ -3124,6 +3477,14 @@ class MainActivity : AppCompatActivity() {
3124
3477
  }
3125
3478
  configureText(this, childText(children), style)
3126
3479
  }
3480
+ "StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3481
+ screen.statusBarStyle = props.optString("barStyle").takeIf { it.isNotBlank() }
3482
+ screen.statusBarHidden = if (props.has("hidden")) props.optBoolean("hidden") else null
3483
+ visibility = View.GONE
3484
+ }
3485
+ "RefreshControl" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3486
+ visibility = View.GONE
3487
+ }
3127
3488
  "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
3128
3489
  includeFontPadding = false
3129
3490
  configureText(this, props.optString("title").ifBlank { childText(children) }, style)
@@ -3133,9 +3494,15 @@ class MainActivity : AppCompatActivity() {
3133
3494
  isEnabled = !props.optBoolean("disabled", false)
3134
3495
  isClickable = true
3135
3496
  isFocusable = true
3497
+ val target = listOf(props.optString("href"), props.optString("to"))
3498
+ .firstOrNull { it.isNotBlank() }
3499
+ ?.trim()
3500
+ ?.takeIf { !props.optBoolean("disabled", false) }
3501
+ if (target != null) screen.linkTargets[this] = target else screen.linkTargets.remove(this)
3136
3502
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
3137
3503
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3138
3504
  setOnClickListener {
3505
+ screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
3139
3506
  val current = screen.handlers[this] ?: return@setOnClickListener
3140
3507
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3141
3508
  }
@@ -3540,7 +3907,7 @@ class MainActivity : AppCompatActivity() {
3540
3907
  if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[this] = onLoadEnd else screen.imageLoadEndHandlers.remove(this)
3541
3908
  val onError = events.optString("onError")
3542
3909
  if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
3543
- updateImage(screen, this, props.opt("source"))
3910
+ updateImage(screen, this, props.opt("source"), props.opt("defaultSource"))
3544
3911
  }
3545
3912
  "ImageBackground" -> (previous as? CraftNativeImageBackgroundView ?: CraftNativeImageBackgroundView(this)).apply {
3546
3913
  image.scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
@@ -3557,7 +3924,7 @@ class MainActivity : AppCompatActivity() {
3557
3924
  if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[image] = onLoadEnd else screen.imageLoadEndHandlers.remove(image)
3558
3925
  val onError = events.optString("onError")
3559
3926
  if (onError.isNotBlank()) screen.imageErrorHandlers[image] = onError else screen.imageErrorHandlers.remove(image)
3560
- updateImage(screen, image, props.opt("source"))
3927
+ updateImage(screen, image, props.opt("source"), props.opt("defaultSource"))
3561
3928
  configureContainer(content, style)
3562
3929
  reconcileChildren(screen, content, children, path, next, style, previousControls)
3563
3930
  }
@@ -3571,6 +3938,13 @@ class MainActivity : AppCompatActivity() {
3571
3938
  controls = props.optBoolean("controls", false),
3572
3939
  )
3573
3940
  }
3941
+ "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
3942
+ configure(props, children) { markerId ->
3943
+ markerHandler(markerId)?.let { handler ->
3944
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject().put("id", markerId)))
3945
+ }
3946
+ }
3947
+ }
3574
3948
  "Svg" -> (previous as? CraftNativeSvgView ?: CraftNativeSvgView(this)).apply {
3575
3949
  drawing = node
3576
3950
  }
@@ -3590,10 +3964,18 @@ class MainActivity : AppCompatActivity() {
3590
3964
  props.optBoolean("alwaysBounceHorizontal", horizontal),
3591
3965
  )
3592
3966
  setPagingEnabled(props.optBoolean("pagingEnabled", false))
3593
- val refreshHandler = events.optString("onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
3967
+ val refreshNode = (0 until children.length()).asSequence()
3968
+ .mapNotNull { children.optJSONObject(it) }
3969
+ .firstOrNull { canonicalType(it.optString("type")) == "RefreshControl" }
3970
+ val refreshProps = refreshNode?.optJSONObject("props") ?: JSONObject()
3971
+ val refreshEvents = refreshNode?.optJSONObject("events") ?: JSONObject()
3972
+ val refreshHandlerName = refreshEvents.optString("onRefresh").takeIf { it.isNotBlank() }
3973
+ ?: events.optString("onRefresh").takeIf { it.isNotBlank() }
3974
+ val refreshHandler = refreshHandlerName?.let { handler ->
3594
3975
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
3595
3976
  }
3596
- setRefreshHandler(refreshHandler, props.optBoolean("refreshing", false))
3977
+ setRefreshHandler(refreshHandler, if (refreshNode == null) props.optBoolean("refreshing", false) else refreshProps.optBoolean("refreshing", false))
3978
+ setRefreshAppearance(refreshProps, resources.displayMetrics.density)
3597
3979
  val onScroll = events.optString("onScroll")
3598
3980
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
3599
3981
  val onBegin = events.optString("onScrollBeginDrag")
@@ -3647,7 +4029,12 @@ class MainActivity : AppCompatActivity() {
3647
4029
  }
3648
4030
  }
3649
4031
  configureContainer(content, contentStyle)
3650
- reconcileChildren(screen, content, children, path, next, contentStyle, previousControls)
4032
+ val contentChildren = JSONArray()
4033
+ for (index in 0 until children.length()) {
4034
+ val child = children.opt(index)
4035
+ if (child !is JSONObject || canonicalType(child.optString("type")) != "RefreshControl") contentChildren.put(child)
4036
+ }
4037
+ reconcileChildren(screen, content, contentChildren, path, next, contentStyle, previousControls)
3651
4038
  }
3652
4039
  "FlatList" -> (previous as? CraftNativeRefreshableFlatList ?: CraftNativeRefreshableFlatList(this)).apply {
3653
4040
  val nativeList = list
@@ -3665,6 +4052,7 @@ class MainActivity : AppCompatActivity() {
3665
4052
  }
3666
4053
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3667
4054
  configureContainer(this, style)
4055
+ if (type == "SafeAreaView") setSafeAreaEdges(props.optJSONArray("edges"))
3668
4056
  reconcileChildren(screen, this, children, path, next, style, previousControls)
3669
4057
  }
3670
4058
  "KeyboardAvoidingView" -> (previous as? CraftNativeKeyboardAvoidingView ?: CraftNativeKeyboardAvoidingView(this)).apply {
@@ -3686,15 +4074,21 @@ class MainActivity : AppCompatActivity() {
3686
4074
  val pressOutHandler = events.optString("onPressOut").takeIf { it.isNotBlank() }
3687
4075
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
3688
4076
  if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
3689
- if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView") {
4077
+ 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") {
3690
4078
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
3691
4079
  val disabled = props.optBoolean("disabled", false)
4080
+ val target = listOf(props.optString("href"), props.optString("to"))
4081
+ .firstOrNull { it.isNotBlank() }
4082
+ ?.trim()
4083
+ ?.takeIf { !disabled && type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") }
4084
+ if (target != null) screen.linkTargets[control] = target else screen.linkTargets.remove(control)
3692
4085
  val hasPressLifecycle = pressInHandler != null || pressOutHandler != null
3693
- if ((handler.isNotBlank() || (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") && hasPressLifecycle)) && !disabled) {
4086
+ if ((handler.isNotBlank() || target != null || (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") && hasPressLifecycle)) && !disabled) {
3694
4087
  if (handler.isNotBlank()) screen.handlers[control] = handler else screen.handlers.remove(control)
3695
4088
  control.isClickable = true
3696
4089
  if (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable")) {
3697
4090
  control.setOnClickListener {
4091
+ screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
3698
4092
  val current = screen.handlers[it] ?: return@setOnClickListener
3699
4093
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3700
4094
  }
@@ -3715,6 +4109,7 @@ class MainActivity : AppCompatActivity() {
3715
4109
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3716
4110
  }
3717
4111
  if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
4112
+ screen.linkTargets[view]?.let { target -> navigateOpen(target); return@setOnTouchListener true }
3718
4113
  val current = screen.handlers[view] ?: return@setOnTouchListener true
3719
4114
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3720
4115
  }
@@ -3726,6 +4121,7 @@ class MainActivity : AppCompatActivity() {
3726
4121
  } else {
3727
4122
  control.setOnTouchListener(null)
3728
4123
  control.setOnClickListener {
4124
+ screen.linkTargets[control]?.let { target -> navigateOpen(target); return@setOnClickListener }
3729
4125
  val current = screen.handlers[control] ?: return@setOnClickListener
3730
4126
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3731
4127
  }
@@ -3833,10 +4229,13 @@ class MainActivity : AppCompatActivity() {
3833
4229
  "switch" -> "Switch"
3834
4230
  "slider" -> "Slider"
3835
4231
  "activityindicator" -> "ActivityIndicator"
4232
+ "statusbar" -> "StatusBar"
4233
+ "refreshcontrol" -> "RefreshControl"
3836
4234
  "image" -> "Image"
3837
4235
  "imagebackground" -> "ImageBackground"
3838
4236
  "keyboardavoidingview" -> "KeyboardAvoidingView"
3839
4237
  "video" -> "Video"
4238
+ "map" -> "Map"
3840
4239
  "icon" -> "Icon"
3841
4240
  "scrollview" -> "ScrollView"
3842
4241
  "sectionlist" -> "SectionList"
@@ -4274,6 +4673,21 @@ class MainActivity : AppCompatActivity() {
4274
4673
 
4275
4674
  private fun applyTextDecoration(view: TextView, style: JSONObject) {
4276
4675
  val line = style.optString("textDecorationLine")
4676
+ if (view is EditText) {
4677
+ // A field's own text, spanned in place: replacing it would move
4678
+ // the caret to the start and tell the screen the text changed.
4679
+ val editable = view.text ?: return
4680
+ editable.getSpans(0, editable.length, CraftTextDecorationSpan::class.java).forEach(editable::removeSpan)
4681
+ if (line.isBlank() || line == "none" || editable.isEmpty()) return
4682
+ val fieldColor = style.optString("textDecorationColor").takeIf { it.isNotBlank() }?.let { color(it, Color.TRANSPARENT) }
4683
+ editable.setSpan(
4684
+ CraftTextDecorationSpan(line, style.optString("textDecorationStyle").ifBlank { "solid" }, fieldColor),
4685
+ 0,
4686
+ editable.length,
4687
+ Spannable.SPAN_INCLUSIVE_INCLUSIVE,
4688
+ )
4689
+ return
4690
+ }
4277
4691
  val text = view.text?.toString() ?: return
4278
4692
  if (line.isBlank() || line == "none" || text.isEmpty()) {
4279
4693
  if (view.text is Spannable) view.text = text
@@ -4311,12 +4725,23 @@ class MainActivity : AppCompatActivity() {
4311
4725
  host == "::1" || host.endsWith(".localhost")
4312
4726
  }
4313
4727
 
4314
- private fun updateImage(screen: Screen, view: ImageView, source: Any?) {
4315
- val uri = when (source) {
4316
- is String -> source
4317
- is JSONObject -> source.optString("uri")
4318
- else -> ""
4728
+ private fun imageUri(source: Any?): String = when (source) {
4729
+ is String -> source
4730
+ is JSONObject -> source.optString("uri")
4731
+ else -> ""
4732
+ }
4733
+
4734
+ private fun immediateImage(uri: String): Bitmap? {
4735
+ if (uri.startsWith("data:image/") && uri.contains(',')) {
4736
+ val bytes = runCatching { Base64.decode(uri.substringAfter(','), Base64.DEFAULT) }.getOrNull()
4737
+ return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
4319
4738
  }
4739
+ if (!uri.contains(':')) return runCatching { assets.open(uri).use { BitmapFactory.decodeStream(it) } }.getOrNull()
4740
+ return null
4741
+ }
4742
+
4743
+ private fun updateImage(screen: Screen, view: ImageView, source: Any?, defaultSource: Any? = null) {
4744
+ val uri = imageUri(source)
4320
4745
  if (uri.isBlank()) {
4321
4746
  if (imageSources[view] == "") return
4322
4747
  imageJobs.remove(view)?.cancel(true)
@@ -4331,6 +4756,7 @@ class MainActivity : AppCompatActivity() {
4331
4756
  imageSources[view] = uri
4332
4757
  imageErrors.remove(view)
4333
4758
  view.setImageDrawable(null)
4759
+ immediateImage(imageUri(defaultSource))?.let(view::setImageBitmap)
4334
4760
  emitImageEvent(screen, view, screen.imageLoadStartHandlers[view])
4335
4761
 
4336
4762
  if (uri.startsWith("data:image/") && uri.contains(',')) {
@@ -4847,6 +5273,7 @@ class MainActivity : AppCompatActivity() {
4847
5273
  if (!preservingInputDrafts) identity?.let(screen.drafts::remove)
4848
5274
  }
4849
5275
  released.forEach(defaultBackgrounds::remove)
5276
+ screen.linkTargets.keys.removeAll(released)
4850
5277
  screen.handlers.keys.removeAll(released)
4851
5278
  screen.pressInHandlers.keys.removeAll(released)
4852
5279
  screen.pressOutHandlers.keys.removeAll(released)