craft-native 0.0.238 → 0.0.242

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/dist/android/src/index.d.ts +23 -1
  2. package/dist/android/src/index.js +102 -9
  3. package/dist/android/src/native-map.d.ts +7 -0
  4. package/dist/android/templates/CraftBridge.kt.template +2 -1
  5. package/dist/android/templates/CraftNativeBackground.kt.template +28 -2
  6. package/dist/android/templates/CraftNativeMapGoogle.kt.template +440 -0
  7. package/dist/android/templates/CraftNativeMapLibre.kt.template +438 -0
  8. package/dist/android/templates/CraftNativeMessagingService.kt.template +3 -1
  9. package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +2 -1
  10. package/dist/android/templates/LocationRecordingService.kt.template +3 -1
  11. package/dist/android/templates/MainActivityNative.kt.template +14 -433
  12. package/dist/android/templates/build.gradle.kts.app.template +1 -1
  13. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  14. package/dist/cli.js +1 -1
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/ios/src/index.d.ts +12 -1
  18. package/dist/ios/src/index.js +32 -7
  19. package/dist/ios/src/testing.d.ts +14 -2
  20. package/dist/ios/templates/CraftApp.swift +117 -29
  21. package/dist/ios/templates/CraftHybrid.swift +193 -29
  22. package/dist/ios/templates/CraftNativeActions.swift +26 -27
  23. package/dist/ios/templates/CraftNativeFiles.swift +205 -1
  24. package/dist/ios/templates/CraftNativeScreen.swift +230 -33
  25. package/dist/ios/templates/testing/DriverTests.swift.template +37 -0
  26. package/dist/macos/templates/native_layout.zig +41 -5
  27. package/package.json +1 -1
@@ -110,17 +110,7 @@ import androidx.javascriptengine.Message
110
110
  import androidx.javascriptengine.MessagePort
111
111
  import androidx.javascriptengine.MessagePortClient
112
112
  import com.craft.runtime.CraftNative
113
- import com.google.android.gms.maps.CameraUpdateFactory
114
- import com.google.android.gms.maps.GoogleMap
115
- import com.google.android.gms.maps.MapView
116
- import com.google.android.gms.maps.OnMapReadyCallback
117
- import com.google.android.gms.maps.model.BitmapDescriptorFactory
118
- import com.google.android.gms.maps.model.LatLng
119
- import com.google.android.gms.maps.model.LatLngBounds
120
- import com.google.android.gms.maps.model.Marker
121
- import com.google.android.gms.maps.model.MarkerOptions
122
- import com.google.android.gms.maps.model.Polyline
123
- import com.google.android.gms.maps.model.PolylineOptions
113
+ {{NATIVE_MAP_IMPORTS}}
124
114
  import org.json.JSONArray
125
115
  import org.json.JSONObject
126
116
  import java.io.File
@@ -597,422 +587,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
597
587
  private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4)
598
588
  }
599
589
 
600
- /** The Android host for the small, host-neutral native trail map contract. */
601
- private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
602
- private val mapView = MapView(context)
603
- private val scaleView = LinearLayout(context).apply {
604
- orientation = LinearLayout.VERTICAL
605
- setPadding(4, 2, 4, 2)
606
- background = GradientDrawable().apply { setColor(Color.argb(230, 255, 255, 255)); cornerRadius = 4f }
607
- importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
608
- }
609
- private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) }
610
- private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) }
611
- private var googleMap: GoogleMap? = null
612
- private var tileOverlay: com.google.android.gms.maps.model.TileOverlay? = null
613
- private var tileTemplate: String? = null
614
- private val tileAttribution = TextView(context).apply { textSize = 9f; setTextColor(Color.DKGRAY); setBackgroundColor(Color.argb(220, 255, 255, 255)); maxLines = 2; visibility = View.GONE }
615
- private var pendingProps = JSONObject()
616
- private var pendingChildren = JSONArray()
617
- private val markers = mutableMapOf<String, Marker>()
618
- private val polylines = mutableMapOf<String, Polyline>()
619
- private val markerHandlers = mutableMapOf<String, String>()
620
- private var onMarkerTap: ((String) -> Unit)? = null
621
- var onMapPress: ((LatLng) -> Unit)? = null
622
- var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
623
- private var userChangingCamera = false
624
- private var markerBearings = mutableMapOf<String, Float>()
625
- private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null
626
- private var lifecycleStarted = false
627
- private var destroyed = false
628
- private var initialCameraKey: String? = null
629
-
630
- init {
631
- addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
632
- scaleView.addView(scaleLabel)
633
- scaleView.addView(scaleLine, LinearLayout.LayoutParams(1, 2))
634
- addView(scaleView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
635
- leftMargin = (8 * resources.displayMetrics.density).roundToInt()
636
- bottomMargin = (24 * resources.displayMetrics.density).roundToInt()
637
- })
638
- scaleView.visibility = View.GONE
639
- addView(tileAttribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply { rightMargin = (8 * resources.displayMetrics.density).roundToInt(); bottomMargin = (8 * resources.displayMetrics.density).roundToInt() })
640
- mapView.onCreate(null)
641
- mapView.getMapAsync(this)
642
- }
643
-
644
- override fun onMapReady(map: GoogleMap) {
645
- if (destroyed) return
646
- googleMap = map
647
- map.setOnMapClickListener { point -> onMapPress?.invoke(point) }
648
- map.setOnCameraMoveStartedListener { reason ->
649
- if (reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) userChangingCamera = true
650
- }
651
- map.setOnCameraMoveListener { updateScale() }
652
- map.setOnCameraIdleListener {
653
- updateScale()
654
- if (userChangingCamera) {
655
- userChangingCamera = false
656
- val bounds = map.projection.visibleRegion.latLngBounds
657
- val position = map.cameraPosition
658
- val longitudeDelta = (bounds.northeast.longitude - bounds.southwest.longitude + 360) % 360
659
- onRegionChangeComplete?.invoke(JSONObject()
660
- .put("center", JSONObject().put("latitude", position.target.latitude).put("longitude", position.target.longitude))
661
- .put("span", JSONObject().put("latitudeDelta", bounds.northeast.latitude - bounds.southwest.latitude).put("longitudeDelta", longitudeDelta))
662
- .put("heading", position.bearing).put("pitch", position.tilt))
663
- }
664
- }
665
- map.setOnMarkerClickListener { marker ->
666
- val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
667
- onMarkerTap?.invoke(id)
668
- // The native contract dispatches the marker event itself. Consume
669
- // the click so Google Maps does not also show an unsolicited info
670
- // window for the marker title.
671
- true
672
- }
673
- applyPending()
674
- }
675
-
676
- fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
677
- pendingProps = props
678
- pendingChildren = children
679
- onMarkerTap = markerHandler
680
- applyPending()
681
- }
682
-
683
- fun markerHandler(id: String): String? = markerHandlers[id]
684
-
685
- private fun applyPending() {
686
- val map = googleMap ?: return
687
- applyMapPadding()
688
- map.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
689
- // The toolbar is replaced by authored actions; the scale uses the native projection.
690
- map.uiSettings.isMapToolbarEnabled = false
691
- map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
692
- map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
693
- map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
694
- map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) {
695
- "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
696
- "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
697
- "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
698
- else -> GoogleMap.MAP_TYPE_NORMAL
699
- }
700
- applyTileOverlay()
701
- val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission()
702
- runCatching { map.isMyLocationEnabled = showUserLocation }
703
- val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
704
- val nextBearings = mutableMapOf<String, Float>()
705
- val nextHandlers = mutableMapOf<String, String>()
706
- val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
707
- for (index in 0 until pendingChildren.length()) {
708
- val child = pendingChildren.optJSONObject(index) ?: continue
709
- val type = child.optString("type").lowercase()
710
- val props = child.optJSONObject("props") ?: JSONObject()
711
- val events = child.optJSONObject("events") ?: JSONObject()
712
- val id = craftNativeOptionalString(props.opt("id"))
713
- ?: craftNativeOptionalString(child.opt("id"))
714
- ?: "overlay-$index"
715
- val points = coordinates(props.optJSONArray("geometry"))
716
- if (type == "mapmarker" || type == "marker") {
717
- points.firstOrNull()?.let { point ->
718
- nextMarkers[id] = point to (props.opt("title") as? String)
719
- (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
720
- listOf(events.opt("onPress"), events.opt("onMarkerPress"))
721
- .firstNotNullOfOrNull(::craftNativeOptionalString)
722
- ?.let { nextHandlers[id] = it }
723
- }
724
- } else if (type == "mappolyline" || type == "polyline") {
725
- if (points.size > 1) {
726
- // Keep malformed authored widths inert, matching the iOS
727
- // host. An omitted or null width still gets the shared
728
- // default; an attacker-controlled NaN, infinity, zero,
729
- // or negative value must not become a valid overlay.
730
- val rawWidth = props.opt("strokeWidth")
731
- val authoredWidth = when (rawWidth) {
732
- null, JSONObject.NULL -> 3.0
733
- is Number -> rawWidth.toDouble()
734
- else -> 3.0
735
- }
736
- // MapKit's lineWidth is expressed in points, while the
737
- // Google Maps SDK consumes screen pixels. Keep the
738
- // protocol in logical points and convert only at this
739
- // Android host boundary so dense displays stay in parity.
740
- val width = (authoredWidth * resources.displayMetrics.density).toFloat()
741
- if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
742
- nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
743
- }
744
- }
745
- }
746
- markerBearings = nextBearings
747
- markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
748
- for ((id, value) in nextMarkers) {
749
- val marker = markers[id]
750
- if (marker != null) {
751
- marker.position = value.first
752
- marker.title = value.second
753
- marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker())
754
- marker.isFlat = markerBearings.containsKey(id)
755
- marker.rotation = markerBearings[id] ?: 0f
756
- marker.setAnchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f)
757
- } else {
758
- map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker()).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it }
759
- }
760
- }
761
- markerHandlers.clear()
762
- markerHandlers.putAll(nextHandlers)
763
- polylines.keys.filter { nextPolylines[it] == null }.forEach { id ->
764
- polylines.remove(id)?.remove()
765
- }
766
- for ((id, value) in nextPolylines) {
767
- val polyline = polylines[id]
768
- if (polyline != null) {
769
- polyline.points = value.first
770
- polyline.color = value.second
771
- polyline.width = value.third
772
- } else {
773
- map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it }
774
- }
775
- }
776
- val explicitRegion = region(pendingProps.optJSONObject("region"))
777
- val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
778
- if (explicitRegion != null) {
779
- // A controlled region is an exact camera request. The padding
780
- // used for fitToBounds is framing chrome, not part of the region
781
- // contract, and would otherwise zoom every Android screen out.
782
- moveCamera(explicitRegion, padding = 0)
783
- initialCameraKey = null
784
- } else if (initialRegion != null) {
785
- val key = "region:${pendingProps.optJSONObject("initialRegion")}"
786
- if (initialCameraKey != key) {
787
- moveCamera(initialRegion, padding = 0)
788
- initialCameraKey = key
789
- }
790
- } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
791
- val key = "fit:${overlayKey(nextMarkers, nextPolylines)}:${pendingProps.optJSONObject("edgePadding")}"
792
- if (initialCameraKey != key) {
793
- if (fitToBounds()) initialCameraKey = key
794
- }
795
- } else {
796
- initialCameraKey = null
797
- }
798
- applyCameraOrientation()
799
- updateScale()
800
- }
801
-
802
- private fun applyTileOverlay() {
803
- val map = googleMap ?: return
804
- val options = pendingProps.optJSONObject("tileOverlay")
805
- val template = options?.optString("urlTemplate").orEmpty()
806
- val credit = options?.optString("attribution").orEmpty()
807
- val next = template.takeIf { it.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { key -> it.contains(key) } && credit.isNotBlank() }
808
- if (next != tileTemplate) {
809
- tileOverlay?.remove(); tileOverlay = null; tileTemplate = next
810
- if (next != null) {
811
- val provider = object : com.google.android.gms.maps.model.UrlTileProvider(256, 256) {
812
- override fun getTileUrl(x: Int, y: Int, zoom: Int): java.net.URL? = runCatching { java.net.URL(next.replace("{x}", x.toString()).replace("{y}", y.toString()).replace("{z}", zoom.toString())) }.getOrNull()
813
- }
814
- tileOverlay = map.addTileOverlay(com.google.android.gms.maps.model.TileOverlayOptions().tileProvider(provider).zIndex(-1f))
815
- }
816
- }
817
- if (next != null) map.mapType = GoogleMap.MAP_TYPE_NONE
818
- tileAttribution.text = credit
819
- tileAttribution.visibility = if (next == null) View.GONE else View.VISIBLE
820
- }
821
-
822
- private fun applyMapPadding() {
823
- val authored = pendingProps.optJSONObject("edgePadding")
824
- fun inset(name: String): Double = (authored?.opt(name) as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 10_000.0)?.times(resources.displayMetrics.density) ?: 0.0
825
- var left = inset("left"); var top = inset("top"); var right = inset("right"); var bottom = inset("bottom")
826
- val horizontal = left + right
827
- val vertical = top + bottom
828
- val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
829
- val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
830
- if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
831
- if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
832
- googleMap?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
833
- }
834
-
835
- private fun updateScale() {
836
- val map = googleMap ?: return
837
- if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scaleView.visibility = View.GONE; return }
838
- val maximumWidth = (80 * resources.displayMetrics.density).roundToInt()
839
- val origin = map.projection.fromScreenLocation(android.graphics.Point(width / 2, height / 2))
840
- val end = map.projection.fromScreenLocation(android.graphics.Point(width / 2 + maximumWidth, height / 2))
841
- val distance = FloatArray(1)
842
- android.location.Location.distanceBetween(origin.latitude, origin.longitude, end.latitude, end.longitude, distance)
843
- val metres = distance[0].toDouble()
844
- if (!metres.isFinite() || metres <= 0) { scaleView.visibility = View.GONE; return }
845
- val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(metres)))
846
- val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= metres }?.times(magnitude) ?: magnitude
847
- scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m"
848
- scaleLine.layoutParams = LinearLayout.LayoutParams((maximumWidth * length / metres).roundToInt().coerceAtLeast(1), 2)
849
- scaleView.visibility = View.VISIBLE
850
- }
851
-
852
- private fun applyCameraOrientation() {
853
- val map = googleMap ?: return
854
- val camera = map.cameraPosition
855
- val pitch = (pendingProps.opt("cameraPitch") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 60.0)?.toFloat() ?: camera.tilt
856
- val heading = (pendingProps.opt("cameraHeading") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.toFloat() ?: camera.bearing
857
- if (kotlin.math.abs(camera.tilt - pitch) > 0.01f || kotlin.math.abs(camera.bearing - heading) > 0.01f) {
858
- map.moveCamera(CameraUpdateFactory.newCameraPosition(com.google.android.gms.maps.model.CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build()))
859
- }
860
- }
861
-
862
- private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor {
863
- directionIcon?.let { return it }
864
- val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
865
- val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
866
- val canvas = Canvas(bitmap)
867
- val paint = Paint(Paint.ANTI_ALIAS_FLAG)
868
- paint.color = Color.WHITE
869
- canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
870
- paint.color = Color.rgb(36, 114, 75)
871
- canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint)
872
- paint.color = Color.WHITE
873
- paint.textSize = size * 0.76f
874
- paint.textAlign = Paint.Align.CENTER
875
- paint.typeface = Typeface.DEFAULT_BOLD
876
- canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint)
877
- return BitmapDescriptorFactory.fromBitmap(bitmap).also { directionIcon = it }
878
- }
879
-
880
- override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
881
- super.onSizeChanged(width, height, oldWidth, oldHeight)
882
- applyMapPadding()
883
- updateScale()
884
- // MapView can become ready before its host has a usable viewport. Do
885
- // not mark a fit as complete until the camera update can be issued.
886
- if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) {
887
- applyPending()
888
- }
889
- }
890
-
891
- private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) {
892
- post {
893
- if (width > 0 && height > 0) {
894
- googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding))
895
- applyCameraOrientation()
896
- }
897
- }
898
- }
899
-
900
- private fun overlayKey(
901
- markers: Map<String, Pair<LatLng, String?>>,
902
- polylines: Map<String, Triple<List<LatLng>, Int, Float>>,
903
- ): String {
904
- val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) ->
905
- "$id:${value.first.latitude},${value.first.longitude}"
906
- }
907
- val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) ->
908
- "$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" }
909
- }
910
- return "m[$markerKey]p[$polylineKey]"
911
- }
912
-
913
- private fun fitToBounds(): Boolean {
914
- val map = googleMap ?: return false
915
- if (width <= 0 || height <= 0) return false
916
- val builder = LatLngBounds.Builder()
917
- var count = 0
918
- markers.values.forEach { builder.include(it.position); count++ }
919
- polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
920
- if (count == 0) return false
921
- post {
922
- if (width > 0 && height > 0) {
923
- applyMapPadding()
924
- val padding = if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt()
925
- map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), padding))
926
- applyCameraOrientation()
927
- }
928
- }
929
- return true
930
- }
931
-
932
- override fun onAttachedToWindow() {
933
- super.onAttachedToWindow()
934
- if (destroyed) return
935
- mapView.onStart()
936
- mapView.onResume()
937
- lifecycleStarted = true
938
- }
939
-
940
- override fun onDetachedFromWindow() {
941
- if (lifecycleStarted) {
942
- mapView.onPause()
943
- mapView.onStop()
944
- lifecycleStarted = false
945
- }
946
- super.onDetachedFromWindow()
947
- }
948
-
949
- fun destroy() {
950
- if (destroyed) return
951
- destroyed = true
952
- if (lifecycleStarted) {
953
- mapView.onPause()
954
- mapView.onStop()
955
- lifecycleStarted = false
956
- }
957
- onMarkerTap = null
958
- onMapPress = null
959
- onRegionChangeComplete = null
960
- directionIcon = null
961
- tileOverlay?.remove(); tileOverlay = null
962
- markerHandlers.clear()
963
- markers.values.forEach { it.remove() }
964
- markers.clear()
965
- polylines.values.forEach { it.remove() }
966
- polylines.clear()
967
- mapView.onDestroy()
968
- googleMap = null
969
- }
970
-
971
- fun lowMemory() {
972
- if (!destroyed) mapView.onLowMemory()
973
- }
974
-
975
- private fun hasLocationPermission(): Boolean =
976
- ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
977
- ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
978
-
979
- private fun coordinates(geometry: JSONArray?): List<LatLng> {
980
- if (geometry == null) return emptyList()
981
- return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
982
- val latitude = finiteNumber(geometry.opt(index)) ?: return@mapNotNull null
983
- val longitude = finiteNumber(geometry.opt(index + 1)) ?: return@mapNotNull null
984
- if (latitude.isFinite() && longitude.isFinite() &&
985
- latitude >= -90.0 && latitude <= 90.0 &&
986
- longitude >= -180.0 && longitude <= 180.0
987
- ) LatLng(latitude, longitude) else null
988
- }
989
- }
990
-
991
- private fun region(value: JSONObject?): LatLngBounds? {
992
- val center = value?.optJSONObject("center") ?: return null
993
- val span = value.optJSONObject("span") ?: return null
994
- val latitude = finiteNumber(center.opt("latitude")) ?: return null
995
- val longitude = finiteNumber(center.opt("longitude")) ?: return null
996
- val latitudeDelta = finiteNumber(span.opt("latitudeDelta"))
997
- ?: finiteNumber(span.opt("latitude_delta"))
998
- ?: return null
999
- val longitudeDelta = finiteNumber(span.opt("longitudeDelta"))
1000
- ?: finiteNumber(span.opt("longitude_delta"))
1001
- ?: return null
1002
- if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() ||
1003
- latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 ||
1004
- latitudeDelta <= 0.0 || latitudeDelta > 180.0 ||
1005
- longitudeDelta <= 0.0 || longitudeDelta > 360.0 ||
1006
- latitude - latitudeDelta / 2.0 < -90.0 || latitude + latitudeDelta / 2.0 > 90.0
1007
- ) return null
1008
- return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
1009
- }
1010
-
1011
- private fun finiteNumber(value: Any?): Double? =
1012
- (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
1013
-
1014
- private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
1015
- }
590
+ {{NATIVE_MAP_HOST}}
1016
591
 
1017
592
  /** A finite celebration owned by a View, never by the JavaScript frame loop. */
1018
593
  internal class CraftNativeCelebration(private val view: View) {
@@ -1882,7 +1457,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1882
1457
  putEnum(base, 16, if (root) sharedAlignItemsCode(alignItems) else 3)
1883
1458
  putEnum(base, 17, if (root) 0 else sharedAlignSelfCode(style.alignSelf))
1884
1459
  putEnum(base, 18, if (root && wrap) 1 else 0)
1885
- putEnum(base, 19, if (root) alignContentCode() else 6)
1460
+ putEnum(base, 19, if (root) alignContentCode() else 5)
1886
1461
  put(base, 20, margins[2].toFloat())
1887
1462
  put(base, 21, margins[1].toFloat())
1888
1463
  put(base, 22, margins[3].toFloat())
@@ -4108,7 +3683,6 @@ class MainActivity : AppCompatActivity() {
4108
3683
  private val backgroundWaiters = mutableMapOf<String, (Boolean) -> Unit>()
4109
3684
  /** A tapped notification that opened the app, for the app script once it runs. */
4110
3685
  private var pendingNotificationTap: JSONObject? = null
4111
- private var bundle = ""
4112
3686
  private lateinit var config: JSONObject
4113
3687
  private lateinit var capabilities: CraftNativeCapabilities
4114
3688
  private val capabilityHandler = Handler(Looper.getMainLooper())
@@ -4219,9 +3793,7 @@ class MainActivity : AppCompatActivity() {
4219
3793
  for (index in 0 until schemes.length()) urlSchemes.add(schemes.optString(index).lowercase())
4220
3794
  if (!routes.isEmpty) installChrome()
4221
3795
  refreshThemeDefaults()
4222
- bundle = runCatching {
4223
- assets.open("native-screen.js").bufferedReader().use { it.readText() }
4224
- }.getOrElse {
3796
+ if (!CraftNativeBundle.isAvailable(this)) {
4225
3797
  showError("Missing native-screen.js. Build with --native-bundle after compiling STX.")
4226
3798
  return
4227
3799
  }
@@ -4787,7 +4359,12 @@ class MainActivity : AppCompatActivity() {
4787
4359
  if (!activeScreen.active) return@addListener
4788
4360
  try {
4789
4361
  setup.get()
4790
- val load = isolate.evaluateJavaScriptAsync(bundle)
4362
+ val source = CraftNativeBundle.source(this, route)
4363
+ if (source == null) {
4364
+ if (activeScreen.active) showError("Missing native-screen.js. Build with --native-bundle after compiling STX.")
4365
+ return@addListener
4366
+ }
4367
+ val load = isolate.evaluateJavaScriptAsync(source)
4791
4368
  load.addListener({
4792
4369
  try {
4793
4370
  load.get()
@@ -7000,6 +6577,7 @@ class MainActivity : AppCompatActivity() {
7000
6577
  val label = craftNativeOptionalString(props.opt("accessibilityLabel"))
7001
6578
  ?: craftNativeOptionalString(props.opt("alt"))?.takeIf { type == "Image" || type == "ImageBackground" }
7002
6579
  val value = craftNativeOptionalString(props.opt("accessibilityValue"))
6580
+ val testId = craftNativeOptionalString(props.opt("testID"))
7003
6581
  view.contentDescription = when {
7004
6582
  label != null && value != null -> "$label, $value"
7005
6583
  label != null -> label
@@ -7024,6 +6602,9 @@ class MainActivity : AppCompatActivity() {
7024
6602
  ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
7025
6603
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
7026
6604
  super.onInitializeAccessibilityNodeInfo(host, info)
6605
+ // Expose STX test identities to Android's native automation
6606
+ // tree without replacing the label read by TalkBack.
6607
+ info.viewIdResourceName = testId
7027
6608
  info.className = when (role) {
7028
6609
  "button" -> Button::class.java.name
7029
6610
  "image" -> ImageView::class.java.name
@@ -99,7 +99,7 @@ dependencies {
99
99
  // Google Play Location Services (for geolocation)
100
100
  implementation("com.google.android.gms:play-services-location:21.1.0")
101
101
 
102
- {{GOOGLE_MAPS_DEPENDENCY}}
102
+ {{NATIVE_MAP_DEPENDENCY}}
103
103
 
104
104
  // Google Play In-App Review
105
105
  implementation("com.google.android.play:review:2.0.1")
@@ -1,6 +1,6 @@
1
1
  // Top-level build file where you can add configuration options common to all sub-projects/modules.
2
2
  plugins {
3
3
  id("com.android.application") version "8.9.1" apply false
4
- id("org.jetbrains.kotlin.android") version "2.0.21" apply false
4
+ id("org.jetbrains.kotlin.android") version "2.2.10" apply false
5
5
  {{GOOGLE_SERVICES_PLUGIN}}
6
6
  }
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.238";
3427
+ var version = "0.0.242";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.238";
313
+ var version = "0.0.242";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.238";
60
+ var version = "0.0.242";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -223,7 +223,12 @@ export interface CraftConfig {
223
223
  * opens in the in-app browser on the app's site.
224
224
  */
225
225
  nativeScreens?: Record<string, string>;
226
- /** The bundle `stx native compile --format bundle` wrote, copied to `dist/native-screen.js`. */
226
+ /**
227
+ * The bundle `stx native compile --format bundle` wrote, copied to
228
+ * `dist/native-screen.js`; or a directory of route bundles (stx's
229
+ * `routesDir`: `<Screen>.js` and `__app.js`), copied to
230
+ * `dist/native-screens/`, so each screen evaluates only its own file.
231
+ */
227
232
  nativeBundle?: string;
228
233
  /**
229
234
  * The bundle route of a screen-less script that runs once for the app's
@@ -307,6 +312,12 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
307
312
  export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
308
313
  /** What is wrong with a config's hybrid and tab settings, one sentence each. */
309
314
  export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp'>): string[];
315
+ /**
316
+ * The builder installs a directory of route bundles (stx's `routesDir`) as
317
+ * well as the one-file bundle, and the app runs each screen from its own
318
+ * route's file. Stacks asks for route bundles only when this is true.
319
+ */
320
+ export declare const nativeRouteBundles = true;
310
321
  export interface CraftBackgroundRefresh {
311
322
  enabled: boolean;
312
323
  /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
@@ -228,10 +228,18 @@ function serializeSimulatorSteps(steps) {
228
228
  if (!Number.isInteger(number(step.index)))
229
229
  throw new Error("field index must be an integer");
230
230
  arg = `${step.index} ${step.text}`;
231
+ } else if ("position" in step) {
232
+ if (number(step.position) > 1)
233
+ throw new Error("Slider position must be between 0 and 1");
234
+ arg = `${token(step.id)} ${step.position}`;
231
235
  } else if ("id" in step)
232
236
  arg = `${token(step.id)} ${step.text}`;
233
237
  else if ("text" in step) {
234
238
  arg = step.text;
239
+ if (step.command === "longpressid")
240
+ token(arg);
241
+ if (step.command === "expectapp" && !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(arg))
242
+ throw new Error("Expected an application bundle identifier");
235
243
  if ("seconds" in step && step.seconds !== undefined) {
236
244
  if (arg.includes("|"))
237
245
  throw new Error("A timed expectation cannot contain |");
@@ -476,13 +484,29 @@ function hybridConfigProblems(config) {
476
484
  }
477
485
  return problems;
478
486
  }
487
+ var nativeRouteBundles = true;
479
488
  function installNativeBundle(source, output) {
480
489
  if (!existsSync(source))
481
490
  throw new Error(`Native bundle not found: ${source}`);
482
- const destination = join3(output, "dist", "native-screen.js");
483
- mkdirSync2(dirname2(destination), { recursive: true });
484
- if (resolve(source) !== resolve(destination))
485
- cpSync(source, destination);
491
+ const single = join3(output, "dist", "native-screen.js");
492
+ const routes = join3(output, "dist", "native-screens");
493
+ mkdirSync2(dirname2(single), { recursive: true });
494
+ if (statSync(source).isDirectory()) {
495
+ if (!readdirSync2(source).some((name) => name.endsWith(".js")))
496
+ throw new Error(`Native route bundles not found in ${source}`);
497
+ if (resolve(source) !== resolve(routes)) {
498
+ rmSync2(routes, { recursive: true, force: true });
499
+ cpSync(source, routes, { recursive: true });
500
+ }
501
+ rmSync2(single, { force: true });
502
+ return;
503
+ }
504
+ if (resolve(source) !== resolve(single))
505
+ cpSync(source, single);
506
+ rmSync2(routes, { recursive: true, force: true });
507
+ }
508
+ function hasInstalledNativeBundle(output) {
509
+ return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
486
510
  }
487
511
  var DEFAULT_CONFIG = {
488
512
  renderer: "web",
@@ -1345,7 +1369,7 @@ async function build(options) {
1345
1369
  const bundle = nativeBundlePath ?? config.nativeBundle;
1346
1370
  if (bundle)
1347
1371
  installNativeBundle(bundle, output);
1348
- if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1372
+ if (!hasInstalledNativeBundle(output)) {
1349
1373
  throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
1350
1374
  }
1351
1375
  } else if (nativeBundlePath && !isHybrid(config)) {
@@ -1370,8 +1394,8 @@ async function build(options) {
1370
1394
  const bundle = nativeBundlePath ?? config.nativeBundle;
1371
1395
  if (bundle) {
1372
1396
  installNativeBundle(bundle, output);
1373
- console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
1374
- } else if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1397
+ console.log(` Native screens: ${bundle} \u2192 ${statSync(bundle).isDirectory() ? "dist/native-screens/" : "dist/native-screen.js"}`);
1398
+ } else if (!hasInstalledNativeBundle(output)) {
1375
1399
  console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
1376
1400
  }
1377
1401
  }
@@ -1544,6 +1568,7 @@ export {
1544
1568
  open,
1545
1569
  normalizeNativePath,
1546
1570
  nativeStartPath,
1571
+ nativeRouteBundles,
1547
1572
  matchNativePath,
1548
1573
  isHybrid,
1549
1574
  installRuntime,