craft-native 0.0.237 → 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.
Files changed (40) hide show
  1. package/README.md +71 -0
  2. package/dist/android/src/emulator.d.ts +23 -0
  3. package/dist/android/src/index.d.ts +24 -2
  4. package/dist/android/src/index.js +362 -103
  5. package/dist/android/src/native-map.d.ts +7 -0
  6. package/dist/android/templates/CraftBridge.kt.template +67 -55
  7. package/dist/android/templates/CraftNative.kt.template +27 -0
  8. package/dist/android/templates/CraftNativeCapabilities.kt.template +36 -5
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +440 -0
  10. package/dist/android/templates/CraftNativeMapLibre.kt.template +438 -0
  11. package/dist/android/templates/CraftNativeMessagingService.kt.template +3 -1
  12. package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +2 -1
  13. package/dist/android/templates/CraftNativeServices.kt.template +45 -35
  14. package/dist/android/templates/CraftNativeShell.kt.template +103 -20
  15. package/dist/android/templates/LocationRecordingService.kt.template +255 -47
  16. package/dist/android/templates/MainActivityNative.kt.template +7 -428
  17. package/dist/android/templates/build.gradle.kts.app.template +1 -1
  18. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  19. package/dist/android-adb.d.ts +2 -0
  20. package/dist/api/index.d.ts +1 -1
  21. package/dist/api/mobile.d.ts +49 -14
  22. package/dist/browser/storage.d.ts +3 -0
  23. package/dist/browser.js +163 -51
  24. package/dist/cli.js +1 -1
  25. package/dist/index.cjs +126 -12
  26. package/dist/index.js +126 -12
  27. package/dist/ios/src/index.d.ts +5 -0
  28. package/dist/ios/src/index.js +17 -1
  29. package/dist/ios/src/testing.d.ts +16 -2
  30. package/dist/ios/templates/CraftApp.swift +418 -93
  31. package/dist/ios/templates/CraftHeartRate.swift +141 -44
  32. package/dist/ios/templates/CraftNativeActions.swift +46 -48
  33. package/dist/ios/templates/CraftNativeScreen.swift +82 -17
  34. package/dist/ios/templates/CraftPersistentStorage.swift +99 -0
  35. package/dist/ios/templates/CraftRecordingArchive.swift +85 -0
  36. package/dist/ios/templates/testing/DriverTests.swift.template +45 -1
  37. package/dist/macos/templates/native_layout.zig +41 -5
  38. package/dist/mobile-testing.js +30 -4
  39. package/dist/mobile.js +169 -17
  40. 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())
@@ -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
  }
@@ -0,0 +1,2 @@
1
+ /** Restart adbd once and require a rooted, explicitly selected disposable emulator. */
2
+ export declare function requireAndroidEmulatorRoot(adb: string, serial: string, timeoutMs?: number): Promise<void>;
@@ -17,7 +17,7 @@ export type { HttpClientOptions, RequestOptions, HttpResponse, WebSocketOptions
17
17
  export { crypto, uuid, randomString, hmac, timingSafeEqual, hashPassword, verifyPassword } from './crypto.js';
18
18
  export { env, getPlatform, isDesktop, isMobile, isCraft, getSystemInfo, exec, spawn, ChildProcess, cwd, homeDir, tempDir, exit, argv, open } from './process.js';
19
19
  export type { Platform, SystemInfo, ExecOptions, ExecResult, SpawnOptions } from './process.js';
20
- export { device, haptics, permissions, camera, biometrics, secureStorage, location, share, lifecycle, notifications, notifications as notification } from './mobile.js';
20
+ export { device, haptics, permissions, camera, biometrics, secureStorage, storage, location, share, lifecycle, notifications, notifications as notification } from './mobile.js';
21
21
  export type { DeviceInfo, DeviceCapabilities, HapticStyle, HapticNotificationType, PermissionType, PermissionStatus, CameraOptions as MobileCameraOptions, PhotoResult, BiometricType, Location, LocationOptions, ShareOptions, AppState, NotificationOptions as MobileNotificationOptions } from './mobile.js';
22
22
  export { carplay, appClips, liveActivities, sharePlay, storeKit, appIntents, tipKit, focusFilters } from './ios-advanced.js';
23
23
  export type { CarPlayTemplateType, CarPlayListItem, CarPlayGridItem, CarPlayTemplate, AppClipInvocation, LiveActivityContentState, LiveActivityAttributes, LiveActivityConfig, LiveActivityHandle, SharePlaySessionState, SharePlayParticipant, SharePlayActivity, ProductType, Product, Transaction, IntentParameterType, IntentParameter, AppIntent, TipDisplayFrequency, Tip, FocusStatus, FocusFilter } from './ios-advanced.js';
@@ -459,6 +459,8 @@ export declare const secureStorage: {
459
459
  * Location coordinates.
460
460
  */
461
461
  export interface Location {
462
+ /** Owner captured when a native recording emits this sample. */
463
+ recordingId?: string;
462
464
  latitude: number;
463
465
  longitude: number;
464
466
  altitude?: number;
@@ -478,6 +480,30 @@ export interface LocationOptions {
478
480
  /** Maximum age of cached position in milliseconds */
479
481
  maximumAge?: number;
480
482
  }
483
+ /** Persist this ID with the owning intent before starting a native recording. */
484
+ export interface RecordingStartOptions {
485
+ recordingId?: string;
486
+ }
487
+ /** Fence a recording operation to the ID returned by start or explicit recovery. */
488
+ export interface RecordingOwnershipOptions {
489
+ expectedId: string;
490
+ }
491
+ /** Secure IDs supplied by the platform, including WebView-free mobile hosts. */
492
+ export declare const crypto: {
493
+ randomUUID(): Promise<string>;
494
+ };
495
+ /** Native storage, including an existing-key atomic checkpoint. */
496
+ export declare const storage: {
497
+ supportsSetIfAbsent(): boolean;
498
+ supportsCompareAndSet(): boolean;
499
+ get<T = unknown>(key: string): Promise<T | null>;
500
+ set(key: string, value: unknown): Promise<boolean>;
501
+ remove(key: string): Promise<boolean>;
502
+ clear(): Promise<boolean>;
503
+ keys(): Promise<string[]>;
504
+ setIfAbsent(key: string, value: unknown): Promise<boolean>;
505
+ compareAndSet(key: string, expectedValue: unknown, nextValue: unknown): Promise<boolean>;
506
+ };
481
507
  export interface LocationRecordingState {
482
508
  id: string | null;
483
509
  active: boolean;
@@ -507,6 +533,8 @@ export interface LocationRecordingResult extends LocationRecordingState {
507
533
  * ```
508
534
  */
509
535
  export declare const location: {
536
+ supportsRecordingOwnership: () => boolean;
537
+ supportsRecordingIntentIdentity: () => boolean;
510
538
  /**
511
539
  * Get current position.
512
540
  *
@@ -528,12 +556,13 @@ export declare const location: {
528
556
  * @param watchId - Watch ID returned from watchPosition
529
557
  */
530
558
  clearWatch(watchId: number): void;
531
- startRecording(options?: LocationOptions): Promise<LocationRecordingState>;
532
- pauseRecording(): Promise<LocationRecordingState>;
533
- resumeRecording(): Promise<LocationRecordingState>;
534
- stopRecording(): Promise<LocationRecordingResult>;
535
- getRecordingState(): Promise<LocationRecordingState>;
536
- readRecording(): Promise<Location[]>;
559
+ startRecording(options?: LocationOptions & RecordingStartOptions): Promise<LocationRecordingState>;
560
+ pauseRecording(options?: RecordingOwnershipOptions): Promise<LocationRecordingState>;
561
+ resumeRecording(options?: RecordingOwnershipOptions): Promise<LocationRecordingState>;
562
+ stopRecording(options?: RecordingOwnershipOptions): Promise<LocationRecordingResult>;
563
+ getRecordingState(options?: RecordingOwnershipOptions): Promise<LocationRecordingState>;
564
+ acknowledgeRecording(options: RecordingOwnershipOptions): Promise<boolean>;
565
+ readRecording(options?: RecordingOwnershipOptions): Promise<Location[]>;
537
566
  };
538
567
  /**
539
568
  * Share options.
@@ -914,7 +943,7 @@ export interface HeartRateSample {
914
943
  /** Milliseconds between beats, when the strap sent them. */
915
944
  rr?: number[];
916
945
  }
917
- export type HeartRateMethod = 'getState' | 'requestAuthorization' | 'scan' | 'stopScan' | 'getDevices' | 'connect' | 'reconnect' | 'disconnect' | 'startRecording' | 'pauseRecording' | 'resumeRecording' | 'stopRecording' | 'getRecordingState' | 'readRecording';
946
+ export type HeartRateMethod = 'getState' | 'requestAuthorization' | 'scan' | 'stopScan' | 'getDevices' | 'connect' | 'reconnect' | 'disconnect' | 'startRecording' | 'pauseRecording' | 'resumeRecording' | 'stopRecording' | 'getRecordingState' | 'readRecording' | 'acknowledgeRecording';
918
947
  export declare function normalizeHeartRateDevice(value: unknown): HeartRateDevice | null;
919
948
  /** A state from native, made safe to render whatever an older or newer build sent. */
920
949
  export declare function normalizeHeartRateState(value: unknown): HeartRateState;
@@ -923,6 +952,8 @@ export declare function normalizeHeartRateReading(value: unknown): HeartRateRead
923
952
  /** A recording's samples, oldest first, one per timestamp, implausible ones left out. */
924
953
  export declare function normalizeHeartRateSamples(value: unknown): HeartRateSample[];
925
954
  export declare const heartRate: {
955
+ supportsRecordingOwnership: () => boolean;
956
+ supportsRecordingIntentIdentity: () => boolean;
926
957
  /** Whether this app can read a heart-rate strap. */
927
958
  isAvailable(): boolean;
928
959
  /** Whether this app build answers `method`; an older build may lack one. */
@@ -955,18 +986,20 @@ export declare const heartRate: {
955
986
  forget?: boolean;
956
987
  }) => Promise<HeartRateState>;
957
988
  /** Keep every reading natively from now on: screen locked, page suspended or app closed. */
958
- startRecording: () => Promise<HeartRateRecordingState>;
959
- pauseRecording: () => Promise<HeartRateRecordingState>;
960
- resumeRecording: () => Promise<HeartRateRecordingState>;
989
+ startRecording(options?: RecordingStartOptions): Promise<HeartRateRecordingState>;
990
+ pauseRecording(options?: RecordingOwnershipOptions): Promise<HeartRateRecordingState>;
991
+ resumeRecording(options?: RecordingOwnershipOptions): Promise<HeartRateRecordingState>;
961
992
  /** Ends the recording and hands back every reading it kept. */
962
- stopRecording: () => Promise<HeartRateRecordingState & {
993
+ stopRecording(options?: RecordingOwnershipOptions): Promise<HeartRateRecordingState & {
963
994
  samples: HeartRateSample[];
964
995
  }>;
965
- getRecordingState: () => Promise<HeartRateRecordingState>;
996
+ getRecordingState(options?: RecordingOwnershipOptions): Promise<HeartRateRecordingState>;
997
+ acknowledgeRecording(options: RecordingOwnershipOptions): Promise<boolean>;
966
998
  /** The recording so far; only what came after `since` (ms) when given. */
967
- readRecording: (options?: {
999
+ readRecording(options?: {
968
1000
  since?: number;
969
- }) => Promise<HeartRateSample[]>;
1001
+ expectedId?: string;
1002
+ }): Promise<HeartRateSample[]>;
970
1003
  /** Every reading from the connected strap, about once a second. */
971
1004
  onReading: (callback: (reading: HeartRateReading) => void) => (() => void);
972
1005
  /** Each strap a scan finds. */
@@ -1685,6 +1718,8 @@ export declare const watchConnectivity: {
1685
1718
  onReachabilityChange(callback: (reachable: boolean) => void): () => void;
1686
1719
  };
1687
1720
  declare const mobile: {
1721
+ crypto: typeof crypto;
1722
+ storage: typeof storage;
1688
1723
  device: typeof device;
1689
1724
  haptics: typeof haptics;
1690
1725
  permissions: typeof permissions;
@@ -6,8 +6,11 @@ export interface BrowserStorageOptions {
6
6
  accountKeys?: string[];
7
7
  hash: (value: string) => Promise<string>;
8
8
  changed: (name: string) => void;
9
+ /** A host lock primitive; browsers use their native Web Locks implementation. */
10
+ lock?: (<T>(name: string, operation: () => T | Promise<T>) => Promise<T>) | null;
9
11
  }
10
12
  export interface BrowserStorage {
13
+ atomicStorage: boolean;
11
14
  craft: {
12
15
  storage: {
13
16
  getSync: (key: string) => unknown;