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
@@ -0,0 +1,440 @@
1
+ /** The Android host for the small, host-neutral native trail map contract. */
2
+ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
3
+ private val mapView = MapView(context)
4
+ private val scaleView = LinearLayout(context).apply {
5
+ orientation = LinearLayout.VERTICAL
6
+ setPadding(4, 2, 4, 2)
7
+ background = GradientDrawable().apply { setColor(Color.argb(230, 255, 255, 255)); cornerRadius = 4f }
8
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
9
+ }
10
+ private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) }
11
+ private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) }
12
+ private var googleMap: GoogleMap? = null
13
+ private var tileOverlay: com.google.android.gms.maps.model.TileOverlay? = null
14
+ private var tileTemplate: String? = null
15
+ private val tileAttribution = TextView(context).apply { textSize = 9f; setTextColor(Color.DKGRAY); setBackgroundColor(Color.argb(220, 255, 255, 255)); maxLines = 2; visibility = View.GONE }
16
+ private var pendingProps = JSONObject()
17
+ private var pendingChildren = JSONArray()
18
+ private val markers = mutableMapOf<String, Marker>()
19
+ private val polylines = mutableMapOf<String, Polyline>()
20
+ private val markerHandlers = mutableMapOf<String, String>()
21
+ private var onMarkerTap: ((String) -> Unit)? = null
22
+ var onMapPress: ((LatLng) -> Unit)? = null
23
+ var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
24
+ private var userChangingCamera = false
25
+ private var markerBearings = mutableMapOf<String, Float>()
26
+ private var markerPinColors = mutableMapOf<String, Int>()
27
+ private val pinIcons = mutableMapOf<Int, com.google.android.gms.maps.model.BitmapDescriptor>()
28
+ private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null
29
+ private var lifecycleStarted = false
30
+ private var destroyed = false
31
+ private var initialCameraKey: String? = null
32
+
33
+ init {
34
+ addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
35
+ scaleView.addView(scaleLabel)
36
+ scaleView.addView(scaleLine, LinearLayout.LayoutParams(1, 2))
37
+ addView(scaleView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
38
+ leftMargin = (8 * resources.displayMetrics.density).roundToInt()
39
+ bottomMargin = (24 * resources.displayMetrics.density).roundToInt()
40
+ })
41
+ scaleView.visibility = View.GONE
42
+ 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() })
43
+ mapView.onCreate(null)
44
+ mapView.getMapAsync(this)
45
+ }
46
+
47
+ override fun onMapReady(map: GoogleMap) {
48
+ if (destroyed) return
49
+ googleMap = map
50
+ map.setOnMapClickListener { point -> onMapPress?.invoke(point) }
51
+ map.setOnCameraMoveStartedListener { reason ->
52
+ if (reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) userChangingCamera = true
53
+ }
54
+ map.setOnCameraMoveListener { updateScale() }
55
+ map.setOnCameraIdleListener {
56
+ updateScale()
57
+ if (userChangingCamera) {
58
+ userChangingCamera = false
59
+ val bounds = map.projection.visibleRegion.latLngBounds
60
+ val position = map.cameraPosition
61
+ val longitudeDelta = (bounds.northeast.longitude - bounds.southwest.longitude + 360) % 360
62
+ onRegionChangeComplete?.invoke(JSONObject()
63
+ .put("center", JSONObject().put("latitude", position.target.latitude).put("longitude", position.target.longitude))
64
+ .put("span", JSONObject().put("latitudeDelta", bounds.northeast.latitude - bounds.southwest.latitude).put("longitudeDelta", longitudeDelta))
65
+ .put("heading", position.bearing).put("pitch", position.tilt))
66
+ }
67
+ }
68
+ map.setOnMarkerClickListener { marker ->
69
+ val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
70
+ onMarkerTap?.invoke(id)
71
+ // The native contract dispatches the marker event itself. Consume
72
+ // the click so Google Maps does not also show an unsolicited info
73
+ // window for the marker title.
74
+ true
75
+ }
76
+ applyPending()
77
+ }
78
+
79
+ fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
80
+ pendingProps = props
81
+ pendingChildren = children
82
+ onMarkerTap = markerHandler
83
+ applyPending()
84
+ }
85
+
86
+ fun markerHandler(id: String): String? = markerHandlers[id]
87
+
88
+ private fun applyPending() {
89
+ val map = googleMap ?: return
90
+ applyMapPadding()
91
+ map.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
92
+ // The toolbar is replaced by authored actions; the scale uses the native projection.
93
+ map.uiSettings.isMapToolbarEnabled = false
94
+ map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
95
+ map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
96
+ map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
97
+ map.uiSettings.isTiltGesturesEnabled = craftNativeBoolean(pendingProps.opt("pitchEnabled"), true)
98
+ map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) {
99
+ "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
100
+ "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
101
+ "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
102
+ else -> GoogleMap.MAP_TYPE_NORMAL
103
+ }
104
+ applyTileOverlay()
105
+ val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission()
106
+ runCatching { map.isMyLocationEnabled = showUserLocation }
107
+ val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
108
+ val nextBearings = mutableMapOf<String, Float>()
109
+ val nextPinColors = mutableMapOf<String, Int>()
110
+ val nextHandlers = mutableMapOf<String, String>()
111
+ val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
112
+ for (index in 0 until pendingChildren.length()) {
113
+ val child = pendingChildren.optJSONObject(index) ?: continue
114
+ val type = child.optString("type").lowercase()
115
+ val props = child.optJSONObject("props") ?: JSONObject()
116
+ val events = child.optJSONObject("events") ?: JSONObject()
117
+ val id = craftNativeOptionalString(props.opt("id"))
118
+ ?: craftNativeOptionalString(child.opt("id"))
119
+ ?: "overlay-$index"
120
+ val points = coordinates(props.optJSONArray("geometry"))
121
+ if (type == "mapmarker" || type == "marker") {
122
+ points.firstOrNull()?.let { point ->
123
+ nextMarkers[id] = point to (props.opt("title") as? String)
124
+ (props.opt("pinColor") as? String)?.let { value -> runCatching { craftNativeColor(value) }.getOrNull()?.let { nextPinColors[id] = it } }
125
+ (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
126
+ listOf(events.opt("onPress"), events.opt("onMarkerPress"))
127
+ .firstNotNullOfOrNull(::craftNativeOptionalString)
128
+ ?.let { nextHandlers[id] = it }
129
+ }
130
+ } else if (type == "mappolyline" || type == "polyline") {
131
+ if (points.size > 1) {
132
+ // Keep malformed authored widths inert, matching the iOS
133
+ // host. An omitted or null width still gets the shared
134
+ // default; an attacker-controlled NaN, infinity, zero,
135
+ // or negative value must not become a valid overlay.
136
+ val rawWidth = props.opt("strokeWidth")
137
+ val authoredWidth = when (rawWidth) {
138
+ null, JSONObject.NULL -> 3.0
139
+ is Number -> rawWidth.toDouble()
140
+ else -> 3.0
141
+ }
142
+ // MapKit's lineWidth is expressed in points, while the
143
+ // Google Maps SDK consumes screen pixels. Keep the
144
+ // protocol in logical points and convert only at this
145
+ // Android host boundary so dense displays stay in parity.
146
+ val width = (authoredWidth * resources.displayMetrics.density).toFloat()
147
+ if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
148
+ nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
149
+ }
150
+ }
151
+ }
152
+ markerBearings = nextBearings
153
+ markerPinColors = nextPinColors
154
+ pinIcons.keys.filter { it !in markerPinColors.values }.forEach { pinIcons.remove(it) }
155
+ markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
156
+ for ((id, value) in nextMarkers) {
157
+ val marker = markers[id]
158
+ if (marker != null) {
159
+ marker.position = value.first
160
+ marker.title = value.second
161
+ marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id))
162
+ marker.isFlat = markerBearings.containsKey(id)
163
+ marker.rotation = markerBearings[id] ?: 0f
164
+ marker.setAnchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f)
165
+ } else {
166
+ map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id)).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it }
167
+ }
168
+ }
169
+ markerHandlers.clear()
170
+ markerHandlers.putAll(nextHandlers)
171
+ polylines.keys.filter { nextPolylines[it] == null }.forEach { id ->
172
+ polylines.remove(id)?.remove()
173
+ }
174
+ for ((id, value) in nextPolylines) {
175
+ val polyline = polylines[id]
176
+ if (polyline != null) {
177
+ polyline.points = value.first
178
+ polyline.color = value.second
179
+ polyline.width = value.third
180
+ } else {
181
+ map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it }
182
+ }
183
+ }
184
+ val explicitRegion = region(pendingProps.optJSONObject("region"))
185
+ val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
186
+ if (explicitRegion != null) {
187
+ // A controlled region is an exact camera request. The padding
188
+ // used for fitToBounds is framing chrome, not part of the region
189
+ // contract, and would otherwise zoom every Android screen out.
190
+ moveCamera(explicitRegion, padding = 0)
191
+ initialCameraKey = null
192
+ } else if (initialRegion != null) {
193
+ val key = "region:${pendingProps.optJSONObject("initialRegion")}"
194
+ if (initialCameraKey != key) {
195
+ moveCamera(initialRegion, padding = 0)
196
+ initialCameraKey = key
197
+ }
198
+ } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
199
+ val key = "fit:${overlayKey(nextMarkers, nextPolylines)}:${pendingProps.optJSONObject("edgePadding")}"
200
+ if (initialCameraKey != key) {
201
+ if (fitToBounds()) initialCameraKey = key
202
+ }
203
+ } else {
204
+ initialCameraKey = null
205
+ }
206
+ applyCameraOrientation()
207
+ updateScale()
208
+ }
209
+
210
+ private fun applyTileOverlay() {
211
+ val map = googleMap ?: return
212
+ val options = pendingProps.optJSONObject("tileOverlay")
213
+ val template = options?.optString("urlTemplate").orEmpty()
214
+ val credit = options?.optString("attribution").orEmpty()
215
+ val next = template.takeIf { it.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { key -> it.contains(key) } && credit.isNotBlank() }
216
+ if (next != tileTemplate) {
217
+ tileOverlay?.remove(); tileOverlay = null; tileTemplate = next
218
+ if (next != null) {
219
+ val provider = object : com.google.android.gms.maps.model.UrlTileProvider(256, 256) {
220
+ 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()
221
+ }
222
+ tileOverlay = map.addTileOverlay(com.google.android.gms.maps.model.TileOverlayOptions().tileProvider(provider).zIndex(-1f))
223
+ }
224
+ }
225
+ if (next != null) map.mapType = GoogleMap.MAP_TYPE_NONE
226
+ tileAttribution.text = credit
227
+ tileAttribution.visibility = if (next == null) View.GONE else View.VISIBLE
228
+ }
229
+
230
+ private fun applyMapPadding() {
231
+ val authored = pendingProps.optJSONObject("edgePadding")
232
+ 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
233
+ var left = inset("left"); var top = inset("top"); var right = inset("right"); var bottom = inset("bottom")
234
+ val horizontal = left + right
235
+ val vertical = top + bottom
236
+ val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
237
+ val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
238
+ if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
239
+ if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
240
+ googleMap?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
241
+ }
242
+
243
+ private fun updateScale() {
244
+ val map = googleMap ?: return
245
+ if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scaleView.visibility = View.GONE; return }
246
+ val maximumWidth = (80 * resources.displayMetrics.density).roundToInt()
247
+ val origin = map.projection.fromScreenLocation(android.graphics.Point(width / 2, height / 2))
248
+ val end = map.projection.fromScreenLocation(android.graphics.Point(width / 2 + maximumWidth, height / 2))
249
+ val distance = FloatArray(1)
250
+ android.location.Location.distanceBetween(origin.latitude, origin.longitude, end.latitude, end.longitude, distance)
251
+ val metres = distance[0].toDouble()
252
+ if (!metres.isFinite() || metres <= 0) { scaleView.visibility = View.GONE; return }
253
+ val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(metres)))
254
+ val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= metres }?.times(magnitude) ?: magnitude
255
+ scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m"
256
+ scaleLine.layoutParams = LinearLayout.LayoutParams((maximumWidth * length / metres).roundToInt().coerceAtLeast(1), 2)
257
+ scaleView.visibility = View.VISIBLE
258
+ }
259
+
260
+ private fun applyCameraOrientation() {
261
+ val map = googleMap ?: return
262
+ val camera = map.cameraPosition
263
+ val pitch = (pendingProps.opt("cameraPitch") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 60.0)?.toFloat() ?: camera.tilt
264
+ val heading = (pendingProps.opt("cameraHeading") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.toFloat() ?: camera.bearing
265
+ if (kotlin.math.abs(camera.tilt - pitch) > 0.01f || kotlin.math.abs(camera.bearing - heading) > 0.01f) {
266
+ map.moveCamera(CameraUpdateFactory.newCameraPosition(com.google.android.gms.maps.model.CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build()))
267
+ }
268
+ }
269
+
270
+ private fun pinMarkerIcon(id: String): com.google.android.gms.maps.model.BitmapDescriptor {
271
+ val color = markerPinColors[id] ?: return BitmapDescriptorFactory.defaultMarker()
272
+ return pinIcons.getOrPut(color) {
273
+ val size = (18 * resources.displayMetrics.density).roundToInt().coerceAtLeast(18)
274
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
275
+ val canvas = Canvas(bitmap)
276
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
277
+ paint.color = Color.WHITE
278
+ canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
279
+ paint.color = color
280
+ canvas.drawCircle(size / 2f, size / 2f, size * 0.38f, paint)
281
+ BitmapDescriptorFactory.fromBitmap(bitmap)
282
+ }
283
+ }
284
+
285
+ private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor {
286
+ directionIcon?.let { return it }
287
+ val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
288
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
289
+ val canvas = Canvas(bitmap)
290
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
291
+ paint.color = Color.WHITE
292
+ canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
293
+ paint.color = Color.rgb(36, 114, 75)
294
+ canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint)
295
+ paint.color = Color.WHITE
296
+ paint.textSize = size * 0.76f
297
+ paint.textAlign = Paint.Align.CENTER
298
+ paint.typeface = Typeface.DEFAULT_BOLD
299
+ canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint)
300
+ return BitmapDescriptorFactory.fromBitmap(bitmap).also { directionIcon = it }
301
+ }
302
+
303
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
304
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
305
+ applyMapPadding()
306
+ updateScale()
307
+ // MapView can become ready before its host has a usable viewport. Do
308
+ // not mark a fit as complete until the camera update can be issued.
309
+ if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) {
310
+ applyPending()
311
+ }
312
+ }
313
+
314
+ private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) {
315
+ post {
316
+ if (width > 0 && height > 0) {
317
+ googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding))
318
+ applyCameraOrientation()
319
+ }
320
+ }
321
+ }
322
+
323
+ private fun overlayKey(
324
+ markers: Map<String, Pair<LatLng, String?>>,
325
+ polylines: Map<String, Triple<List<LatLng>, Int, Float>>,
326
+ ): String {
327
+ val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) ->
328
+ "$id:${value.first.latitude},${value.first.longitude}"
329
+ }
330
+ val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) ->
331
+ "$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" }
332
+ }
333
+ return "m[$markerKey]p[$polylineKey]"
334
+ }
335
+
336
+ private fun fitToBounds(): Boolean {
337
+ val map = googleMap ?: return false
338
+ if (width <= 0 || height <= 0) return false
339
+ val builder = LatLngBounds.Builder()
340
+ var count = 0
341
+ markers.values.forEach { builder.include(it.position); count++ }
342
+ polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
343
+ if (count == 0) return false
344
+ post {
345
+ if (width > 0 && height > 0) {
346
+ applyMapPadding()
347
+ val padding = if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt()
348
+ map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), padding))
349
+ applyCameraOrientation()
350
+ }
351
+ }
352
+ return true
353
+ }
354
+
355
+ override fun onAttachedToWindow() {
356
+ super.onAttachedToWindow()
357
+ if (destroyed) return
358
+ mapView.onStart()
359
+ mapView.onResume()
360
+ lifecycleStarted = true
361
+ }
362
+
363
+ override fun onDetachedFromWindow() {
364
+ if (lifecycleStarted) {
365
+ mapView.onPause()
366
+ mapView.onStop()
367
+ lifecycleStarted = false
368
+ }
369
+ super.onDetachedFromWindow()
370
+ }
371
+
372
+ fun destroy() {
373
+ if (destroyed) return
374
+ destroyed = true
375
+ if (lifecycleStarted) {
376
+ mapView.onPause()
377
+ mapView.onStop()
378
+ lifecycleStarted = false
379
+ }
380
+ onMarkerTap = null
381
+ onMapPress = null
382
+ onRegionChangeComplete = null
383
+ directionIcon = null
384
+ pinIcons.clear(); markerPinColors.clear()
385
+ tileOverlay?.remove(); tileOverlay = null
386
+ markerHandlers.clear()
387
+ markers.values.forEach { it.remove() }
388
+ markers.clear()
389
+ polylines.values.forEach { it.remove() }
390
+ polylines.clear()
391
+ mapView.onDestroy()
392
+ googleMap = null
393
+ }
394
+
395
+ fun lowMemory() {
396
+ if (!destroyed) mapView.onLowMemory()
397
+ }
398
+
399
+ private fun hasLocationPermission(): Boolean =
400
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
401
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
402
+
403
+ private fun coordinates(geometry: JSONArray?): List<LatLng> {
404
+ if (geometry == null) return emptyList()
405
+ return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
406
+ val latitude = finiteNumber(geometry.opt(index)) ?: return@mapNotNull null
407
+ val longitude = finiteNumber(geometry.opt(index + 1)) ?: return@mapNotNull null
408
+ if (latitude.isFinite() && longitude.isFinite() &&
409
+ latitude >= -90.0 && latitude <= 90.0 &&
410
+ longitude >= -180.0 && longitude <= 180.0
411
+ ) LatLng(latitude, longitude) else null
412
+ }
413
+ }
414
+
415
+ private fun region(value: JSONObject?): LatLngBounds? {
416
+ val center = value?.optJSONObject("center") ?: return null
417
+ val span = value.optJSONObject("span") ?: return null
418
+ val latitude = finiteNumber(center.opt("latitude")) ?: return null
419
+ val longitude = finiteNumber(center.opt("longitude")) ?: return null
420
+ val latitudeDelta = finiteNumber(span.opt("latitudeDelta"))
421
+ ?: finiteNumber(span.opt("latitude_delta"))
422
+ ?: return null
423
+ val longitudeDelta = finiteNumber(span.opt("longitudeDelta"))
424
+ ?: finiteNumber(span.opt("longitude_delta"))
425
+ ?: return null
426
+ if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() ||
427
+ latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 ||
428
+ latitudeDelta <= 0.0 || latitudeDelta > 180.0 ||
429
+ longitudeDelta <= 0.0 || longitudeDelta > 360.0 ||
430
+ latitude - latitudeDelta / 2.0 < -90.0 || latitude + latitudeDelta / 2.0 > 90.0
431
+ ) return null
432
+ return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
433
+ }
434
+
435
+ private fun finiteNumber(value: Any?): Double? =
436
+ (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
437
+
438
+ private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
439
+ }
440
+