ReviewOS

also looking at this

craft-native/craft

feat(maps): add key-free native Android maps and shared map controls

#363
Merged chris wants to merge codex/wildloop-native-map-providers into main
28 files +1857 -481

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

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