craft-native 0.0.238 → 0.0.241

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.
@@ -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())
@@ -7000,6 +6575,7 @@ class MainActivity : AppCompatActivity() {
7000
6575
  val label = craftNativeOptionalString(props.opt("accessibilityLabel"))
7001
6576
  ?: craftNativeOptionalString(props.opt("alt"))?.takeIf { type == "Image" || type == "ImageBackground" }
7002
6577
  val value = craftNativeOptionalString(props.opt("accessibilityValue"))
6578
+ val testId = craftNativeOptionalString(props.opt("testID"))
7003
6579
  view.contentDescription = when {
7004
6580
  label != null && value != null -> "$label, $value"
7005
6581
  label != null -> label
@@ -7024,6 +6600,9 @@ class MainActivity : AppCompatActivity() {
7024
6600
  ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
7025
6601
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
7026
6602
  super.onInitializeAccessibilityNodeInfo(host, info)
6603
+ // Expose STX test identities to Android's native automation
6604
+ // tree without replacing the label read by TalkBack.
6605
+ info.viewIdResourceName = testId
7027
6606
  info.className = when (role) {
7028
6607
  "button" -> Button::class.java.name
7029
6608
  "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.241";
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.241";
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.241";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -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 |");
@@ -66,15 +66,27 @@ export type SimulatorStep = {
66
66
  index: number;
67
67
  text: string;
68
68
  } | {
69
- command: 'typeid' | 'labelid';
69
+ command: 'typeid' | 'labelid' | 'valueid';
70
70
  id: string;
71
71
  text: string;
72
+ }
73
+ /** Adjust an actual native UISlider by identifier, using its normalized range. */
74
+ | {
75
+ command: 'sliderid';
76
+ id: string;
77
+ position: number;
78
+ }
79
+ /** Assert a handoff reached the foreground application without launching it. */
80
+ | {
81
+ command: 'expectapp';
82
+ text: string;
83
+ seconds?: number;
72
84
  } | {
73
85
  command: 'expect' | 'expectexact' | 'expectid' | 'systemexpect';
74
86
  text: string;
75
87
  seconds?: number;
76
88
  } | {
77
- command: 'tap' | 'tapif' | 'tapid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log' | 'systemtap' | 'systemabsent';
89
+ command: 'tap' | 'tapif' | 'tapid' | 'longpressid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log' | 'systemtap' | 'systemabsent';
78
90
  text: string;
79
91
  } | {
80
92
  command: 'shot' | 'tree' | 'audit' | 'exec' | 'systemtree';