also looking at this
feat(maps): add key-free native Android maps and shared map controls
#363
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.
| @@ -915,6 +915,7 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 915 | 915 | config.renderer === 'native' ? 'MainActivityNative.kt.template' : 'MainActivity.kt.template', |
| 916 | 916 | ), 'utf-8') |
| 917 | 917 | const mainActivity = mainActivityTemplate |
| 918 | .replace(/\{\{NATIVE_MAP_HOST\}\}/g, () => readFileSync(join(TEMPLATES_DIR, 'CraftNativeMapGoogle.kt.template'), 'utf8')) | |
| 918 | 919 | .replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName) |
| 919 | 920 | .replace(/\{\{APP_NAME\}\}/g, name) |
| 920 | 921 | |
| @@ -0,0 +1,417 @@ | ||
| 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 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 | ||
| @@ -597,422 +597,7 @@ private class CraftNativeSvgView(context: Context) : View(context) { | ||
| 597 | 597 | private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4) |
| 598 | 598 | } |
| 599 | 599 | |
| 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 | } | |
| 600 | ||
| 1016 | 601 | |
| 1017 | 602 | /** A finite celebration owned by a View, never by the JavaScript frame loop. */ |
| 1018 | 603 | internal class CraftNativeCelebration(private val view: View) { |