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+10-5
Changes to packages/android/src/index.ts
@@ -1,3 +1,4 @@
1import { androidMapHost, type AndroidMapProvider } from './native-map'
12/**
23 * Craft Android Builder
34 *
@@ -155,7 +156,9 @@ export interface CraftAndroidConfig {
155156 trustedOrigins?: string[]
156157 appIconPath?: string
157158 googleServicesFile?: string
158 /** Google Maps Android SDK key used by native `Map` screens. */
159 /** Native map renderer. MapLibre needs no SDK key; Google remains the default for compatibility. */
160 mapProvider?: AndroidMapProvider
161 /** Google Maps Android SDK key, used only by the Google native map host. */
159162 mapsApiKey?: string
160163 devServerURL?: string
161164 hasBundledFallback?: boolean
@@ -440,6 +443,7 @@ function normalizeAndroidNetworkConfig(config: CraftAndroidConfig): void {
440443}
441444
442445function validateAndroidConfig(config: CraftAndroidConfig): void {
446 androidMapHost(config.mapProvider)
443447 if (!config.appName.trim()) throw new Error('Android app name must not be empty')
444448 if (config.renderer !== undefined && config.renderer !== 'web' && config.renderer !== 'native') {
445449 throw new Error(`Unknown Android renderer: ${config.renderer}. Expected web or native.`)
@@ -915,7 +919,8 @@ export async function init(options: InitOptions): Promise<void> {
915919 config.renderer === 'native' ? 'MainActivityNative.kt.template' : 'MainActivity.kt.template',
916920 ), 'utf-8')
917921 const mainActivity = mainActivityTemplate
918 .replace(/\{\{NATIVE_MAP_HOST\}\}/g, () => readFileSync(join(TEMPLATES_DIR, 'CraftNativeMapGoogle.kt.template'), 'utf8'))
922 .replace(/\{\{NATIVE_MAP_IMPORTS\}\}/g, () => androidMapHost(config.mapProvider).imports)
923 .replace(/\{\{NATIVE_MAP_HOST\}\}/g, () => readFileSync(join(TEMPLATES_DIR, androidMapHost(config.mapProvider).template), 'utf8'))
919924 .replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName)
920925 .replace(/\{\{APP_NAME\}\}/g, name)
921926
@@ -1042,7 +1047,7 @@ export async function init(options: InitOptions): Promise<void> {
10421047 // Starts the crash reporter before Application.onCreate, for every way
10431048 // the process starts (CraftCrashReporter.kt). Inert without a key.
10441049 .replace(/\{\{CRASH_REPORTER_PROVIDER\}\}/g, ` <provider android:name=".CraftCrashReporterInit" android:authorities="\${applicationId}.craft-crash" android:exported="false" android:initOrder="1000" />`)
1045 .replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapsApiKey
1050 .replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapProvider !== 'maplibre' && config.mapsApiKey
10461051 ? ` <meta-data android:name="com.google.android.geo.API_KEY" android:value="${escapeXml(config.mapsApiKey)}" />`
10471052 : '')
10481053 .replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect
@@ -1081,8 +1086,8 @@ export async function init(options: InitOptions): Promise<void> {
10811086 .replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === 'native'
10821087 ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")'
10831088 : '')
1084 .replace(/\{\{GOOGLE_MAPS_DEPENDENCY\}\}/g, config.renderer === 'native'
1085 ? ' // Native STX Map overlays and trail routes\n implementation("com.google.android.gms:play-services-maps:18.2.0")'
1089 .replace(/\{\{NATIVE_MAP_DEPENDENCY\}\}/g, config.renderer === 'native'
1090 ? ` // Native STX Map overlays and trail routes\n implementation("${androidMapHost(config.mapProvider).dependency}")`
10861091 : '')
10871092 .replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect
10881093 ? ` implementation("androidx.health.connect:connect-client:1.1.0")
packages/android/src/native-map.tsadded+41-0
Changes to packages/android/src/native-map.ts
@@ -0,0 +1,41 @@
1/** Build-time native map selection. Both providers are native Android views. */
2export type AndroidMapProvider = 'google' | 'maplibre'
3
4export function androidMapHost(provider: AndroidMapProvider = 'google'): { template: string, imports: string, dependency: string } {
5 if (provider === 'google') return {
6 template: 'CraftNativeMapGoogle.kt.template',
7 imports: `import com.google.android.gms.maps.CameraUpdateFactory
8import com.google.android.gms.maps.GoogleMap
9import com.google.android.gms.maps.MapView
10import com.google.android.gms.maps.OnMapReadyCallback
11import com.google.android.gms.maps.model.BitmapDescriptorFactory
12import com.google.android.gms.maps.model.LatLng
13import com.google.android.gms.maps.model.LatLngBounds
14import com.google.android.gms.maps.model.Marker
15import com.google.android.gms.maps.model.MarkerOptions
16import com.google.android.gms.maps.model.Polyline
17import com.google.android.gms.maps.model.PolylineOptions`,
18 dependency: 'com.google.android.gms:play-services-maps:18.2.0',
19 }
20 if (provider === 'maplibre') return {
21 template: 'CraftNativeMapLibre.kt.template',
22 imports: `import org.maplibre.android.MapLibre
23import org.maplibre.android.camera.CameraPosition
24import org.maplibre.android.camera.CameraUpdateFactory
25import org.maplibre.android.geometry.LatLng
26import org.maplibre.android.geometry.LatLngBounds
27import org.maplibre.android.location.LocationComponentActivationOptions
28import org.maplibre.android.maps.MapLibreMap
29import org.maplibre.android.maps.MapView
30import org.maplibre.android.maps.OnMapReadyCallback
31import org.maplibre.android.maps.Style
32import org.maplibre.android.style.expressions.Expression
33import org.maplibre.android.style.layers.CircleLayer
34import org.maplibre.android.style.layers.LineLayer
35import org.maplibre.android.style.layers.SymbolLayer
36import org.maplibre.android.style.layers.PropertyFactory.*
37import org.maplibre.android.style.sources.GeoJsonSource`,
38 dependency: 'org.maplibre.gl:android-sdk:13.7.1',
39 }
40 throw new Error(`Unknown Android native map provider: ${provider}. Expected google or maplibre.`)
41}
packages/android/templates/CraftNativeMapLibre.kt.templateadded+354-0
Changes to packages/android/templates/CraftNativeMapLibre.kt.template
@@ -0,0 +1,354 @@
1/** A key-free native Android map. STX geometry never enters a WebView. */
2internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
3 private val mapView: MapView
4 private var map: MapLibreMap? = null
5 private var destroyed = false
6 private var lifecycleStarted = false
7 private var pendingProps = JSONObject()
8 private var pendingChildren = JSONArray()
9 private var styleKey: String? = null
10 private var styleVersion = 0
11 private var readyStyle: Style? = null
12 private var initialCameraKey: String? = null
13 private var userChangingCamera = false
14 private var geometryPoints = emptyList<LatLng>()
15 private val markerHandlers = mutableMapOf<String, String>()
16 private var onMarkerTap: ((String) -> Unit)? = null
17 var onMapPress: ((LatLng) -> Unit)? = null
18 var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
19 private val attribution = TextView(context).apply {
20 textSize = 9f
21 setTextColor(Color.DKGRAY)
22 setBackgroundColor(Color.argb(230, 255, 255, 255))
23 maxLines = 2
24 }
25 private val scale = LinearLayout(context).apply {
26 orientation = LinearLayout.VERTICAL
27 setPadding(4, 2, 4, 2)
28 setBackgroundColor(Color.argb(230, 255, 255, 255))
29 importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
30 visibility = View.GONE
31 }
32 private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) }
33 private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) }
34
35 init {
36 MapLibre.getInstance(context)
37 mapView = MapView(context)
38 addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
39 scale.addView(scaleLabel)
40 scale.addView(scaleLine, LinearLayout.LayoutParams(1, 2))
41 addView(scale, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
42 leftMargin = (8 * resources.displayMetrics.density).roundToInt()
43 bottomMargin = (24 * resources.displayMetrics.density).roundToInt()
44 })
45 addView(attribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply {
46 rightMargin = (8 * resources.displayMetrics.density).roundToInt()
47 bottomMargin = (8 * resources.displayMetrics.density).roundToInt()
48 })
49 mapView.onCreate(null)
50 mapView.getMapAsync(this)
51 }
52
53 override fun onMapReady(nativeMap: MapLibreMap) {
54 if (destroyed) return
55 map = nativeMap
56 nativeMap.addOnMapClickListener { point ->
57 val screen = nativeMap.projection.toScreenLocation(point)
58 val radius = 18 * resources.displayMetrics.density
59 val hit = nativeMap.queryRenderedFeatures(android.graphics.RectF(screen.x - radius, screen.y - radius, screen.x + radius, screen.y + radius), "craft-map-markers", "craft-map-directions").firstOrNull()
60 val id = hit?.takeIf { it.hasProperty("craftMarkerId") }?.getStringProperty("craftMarkerId")
61 if (id != null) onMarkerTap?.invoke(id) else onMapPress?.invoke(point)
62 true
63 }
64 nativeMap.addOnCameraMoveStartedListener { reason ->
65 if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) userChangingCamera = true
66 }
67 nativeMap.addOnCameraMoveListener { updateScale() }
68 nativeMap.addOnCameraIdleListener {
69 updateScale()
70 if (userChangingCamera) {
71 userChangingCamera = false
72 val bounds = nativeMap.projection.visibleRegion.latLngBounds
73 val camera = nativeMap.cameraPosition
74 val center = camera.target
75 if (center != null) onRegionChangeComplete?.invoke(JSONObject()
76 .put("center", JSONObject().put("latitude", center.latitude).put("longitude", center.longitude))
77 .put("span", JSONObject().put("latitudeDelta", bounds.latitudeNorth - bounds.latitudeSouth).put("longitudeDelta", bounds.longitudeEast - bounds.longitudeWest))
78 .put("heading", camera.bearing).put("pitch", camera.tilt))
79 }
80 }
81 applyPending()
82 }
83
84 fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
85 pendingProps = props
86 pendingChildren = children
87 onMarkerTap = markerHandler
88 applyPending()
89 }
90
91 fun markerHandler(id: String): String? = markerHandlers[id]
92
93 private fun applyPending() {
94 val nativeMap = map ?: return
95 if (destroyed) return
96 nativeMap.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
97 nativeMap.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
98 nativeMap.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
99 nativeMap.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
100 nativeMap.uiSettings.isTiltGesturesEnabled = true
101 // The authored attribution is kept visible rather than hidden in a menu.
102 nativeMap.uiSettings.isAttributionEnabled = false
103 nativeMap.uiSettings.isLogoEnabled = false
104 applyPadding()
105 val nextStyle = basemapStyle()
106 if (styleKey != nextStyle) {
107 styleKey = nextStyle
108 readyStyle = null
109 val version = ++styleVersion
110 nativeMap.setStyle(Style.Builder().fromJson(nextStyle)) { loaded ->
111 if (!destroyed && version == styleVersion) {
112 readyStyle = loaded
113 installOverlays(loaded)
114 applyPending()
115 }
116 }
117 return
118 }
119 val loaded = readyStyle ?: return
120 updateOverlays(loaded)
121 applyLocation(nativeMap, loaded)
122 val explicit = region(pendingProps.optJSONObject("region"))
123 val initial = region(pendingProps.optJSONObject("initialRegion"))
124 if (explicit != null) {
125 moveCamera(explicit, 0)
126 initialCameraKey = null
127 } else if (initial != null) {
128 val key = "region:${pendingProps.optJSONObject("initialRegion")}"
129 if (initialCameraKey != key) { moveCamera(initial, 0); initialCameraKey = key }
130 } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
131 val key = geometryPoints.joinToString(";") { "${it.latitude},${it.longitude}" }
132 if (initialCameraKey != key && geometryPoints.isNotEmpty() && width > 0 && height > 0) {
133 if (geometryPoints.size == 1) nativeMap.moveCamera(CameraUpdateFactory.newLatLngZoom(geometryPoints.first(), 15.0))
134 else moveCamera(LatLngBounds.Builder().includes(geometryPoints).build(), if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt())
135 initialCameraKey = key
136 }
137 } else initialCameraKey = null
138 applyOrientation()
139 updateScale()
140 }
141
142 private fun basemapStyle(): String {
143 val custom = pendingProps.optJSONObject("tileOverlay")
144 val template = custom?.optString("urlTemplate").orEmpty()
145 val credit = custom?.optString("attribution").orEmpty()
146 val customValid = template.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { template.contains(it) } && credit.isNotBlank()
147 val type = pendingProps.optString("mapType", "standard").lowercase()
148 val satellite = type == "satellite" || type == "hybrid"
149 val url = when {
150 customValid -> template
151 satellite -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
152 type == "terrain" -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"
153 else -> "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
154 }
155 val attributionText = when {
156 customValid -> credit
157 satellite -> "Imagery © Esri, Maxar, Earthstar Geographics, and the GIS User Community"
158 type == "terrain" -> "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"
159 else -> "© OpenStreetMap contributors"
160 }
161 attribution.text = attributionText
162 val sources = JSONObject().put("basemap", rasterSource(url, attributionText))
163 val layers = JSONArray().put(JSONObject().put("id", "basemap").put("type", "raster").put("source", "basemap"))
164 if (!customValid && type == "hybrid") {
165 sources.put("labels", rasterSource("https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", "Labels © Esri"))
166 layers.put(JSONObject().put("id", "labels").put("type", "raster").put("source", "labels"))
167 attribution.text = "$attributionText · Labels © Esri"
168 }
169 return JSONObject().put("version", 8).put("sources", sources).put("layers", layers).toString()
170 }
171
172 private fun rasterSource(url: String, credit: String): JSONObject = JSONObject()
173 .put("type", "raster").put("tiles", JSONArray().put(url)).put("tileSize", 256).put("maxzoom", 19).put("attribution", credit)
174
175 private fun installOverlays(style: Style) {
176 style.addSource(GeoJsonSource("craft-map-overlays"))
177 style.addImage("craft-map-direction", directionBitmap())
178 style.addLayer(LineLayer("craft-map-routes", "craft-map-overlays")
179 .withFilter(Expression.eq(Expression.get("kind"), "line"))
180 .withProperties(lineColor(Expression.toColor(Expression.get("color"))), lineWidth(Expression.get("width")), lineCap("round"), lineJoin("round")))
181 style.addLayer(CircleLayer("craft-map-markers", "craft-map-overlays")
182 .withFilter(Expression.eq(Expression.get("kind"), "marker"))
183 .withProperties(circleColor(Expression.toColor(Expression.get("color"))), circleRadius(7f), circleStrokeColor(Color.WHITE), circleStrokeWidth(2f)))
184 style.addLayer(SymbolLayer("craft-map-directions", "craft-map-overlays")
185 .withFilter(Expression.eq(Expression.get("kind"), "direction"))
186 .withProperties(iconImage("craft-map-direction"), iconRotate(Expression.get("bearing")), iconRotationAlignment("map"), iconAllowOverlap(true), iconIgnorePlacement(true), iconAnchor("center")))
187 }
188
189 private fun updateOverlays(style: Style) {
190 val features = JSONArray()
191 val pointsForFit = mutableListOf<LatLng>()
192 markerHandlers.clear()
193 for (index in 0 until pendingChildren.length()) {
194 val child = pendingChildren.optJSONObject(index) ?: continue
195 val type = child.optString("type").lowercase()
196 val props = child.optJSONObject("props") ?: JSONObject()
197 val events = child.optJSONObject("events") ?: JSONObject()
198 val id = craftNativeOptionalString(props.opt("id")) ?: craftNativeOptionalString(child.opt("id")) ?: "overlay-$index"
199 val points = coordinates(props.optJSONArray("geometry"))
200 val properties = JSONObject()
201 val geometry = JSONObject()
202 if ((type == "mapmarker" || type == "marker") && points.isNotEmpty()) {
203 val point = points.first()
204 val bearing = finiteNumber(props.opt("bearing"))
205 properties.put("kind", if (bearing == null) "marker" else "direction").put("craftMarkerId", id)
206 properties.put("bearing", (bearing ?: 0.0) % 360).put("color", cssColor(props.opt("pinColor"), Color.rgb(36, 114, 75)))
207 geometry.put("type", "Point").put("coordinates", JSONArray().put(point.longitude).put(point.latitude))
208 listOf(events.opt("onPress"), events.opt("onMarkerPress")).firstNotNullOfOrNull(::craftNativeOptionalString)?.let { markerHandlers[id] = it }
209 pointsForFit.add(point)
210 } else if ((type == "mappolyline" || type == "polyline") && points.size > 1) {
211 val authoredWidth = props.opt("strokeWidth")
212 val width = if (authoredWidth == null || authoredWidth == JSONObject.NULL) 3.0 else finiteNumber(authoredWidth) ?: continue
213 if (width <= 0 || !width.toFloat().isFinite()) continue
214 properties.put("kind", "line").put("color", cssColor(props.opt("strokeColor"), Color.BLUE)).put("width", width)
215 geometry.put("type", "LineString").put("coordinates", JSONArray().apply { points.forEach { put(JSONArray().put(it.longitude).put(it.latitude)) } })
216 pointsForFit.addAll(points)
217 } else continue
218 features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry))
219 }
220 geometryPoints = pointsForFit
221 style.getSourceAs<GeoJsonSource>("craft-map-overlays")?.setGeoJson(JSONObject().put("type", "FeatureCollection").put("features", features).toString())
222 }
223
224 private fun cssColor(value: Any?, fallback: Int): String {
225 val color = (value as? String)?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback
226 return "rgba(${Color.red(color)},${Color.green(color)},${Color.blue(color)},${Color.alpha(color) / 255.0})"
227 }
228
229 private fun directionBitmap(): Bitmap {
230 val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
231 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
232 bitmap.density = resources.displayMetrics.densityDpi
233 val canvas = Canvas(bitmap)
234 val paint = Paint(Paint.ANTI_ALIAS_FLAG)
235 paint.color = Color.WHITE
236 canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
237 paint.color = Color.rgb(36, 114, 75)
238 canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint)
239 paint.color = Color.WHITE
240 paint.textSize = size * 0.76f
241 paint.textAlign = Paint.Align.CENTER
242 paint.typeface = Typeface.DEFAULT_BOLD
243 canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint)
244 return bitmap
245 }
246
247 private fun applyLocation(nativeMap: MapLibreMap, style: Style) {
248 val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
249 ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
250 val enabled = permitted && craftNativeBoolean(pendingProps.opt("showsUserLocation"))
251 val component = nativeMap.locationComponent
252 if (!enabled && !component.isLocationComponentActivated) return
253 try {
254 if (!component.isLocationComponentActivated) component.activateLocationComponent(LocationComponentActivationOptions.builder(context, style).useDefaultLocationEngine(true).build())
255 component.isLocationComponentEnabled = enabled
256 } catch (error: SecurityException) {
257 // Permission may be revoked between the check and native activation.
258 if (component.isLocationComponentActivated) component.isLocationComponentEnabled = false
259 }
260 }
261
262 private fun applyPadding() {
263 val value = pendingProps.optJSONObject("edgePadding")
264 fun inset(key: String): Double = (finiteNumber(value?.opt(key)) ?: 0.0).coerceIn(0.0, 10_000.0) * resources.displayMetrics.density
265 var left = inset("left"); var right = inset("right"); var top = inset("top"); var bottom = inset("bottom")
266 val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f)
267 val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f)
268 val horizontal = left + right; val vertical = top + bottom
269 if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
270 if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
271 map?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
272 }
273
274 private fun moveCamera(bounds: LatLngBounds, padding: Int) {
275 post { if (!destroyed && width > 0 && height > 0) { map?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)); applyOrientation() } }
276 }
277
278 private fun applyOrientation() {
279 val nativeMap = map ?: return
280 val camera = nativeMap.cameraPosition
281 val pitch = finiteNumber(pendingProps.opt("cameraPitch"))?.coerceIn(0.0, 60.0) ?: camera.tilt
282 val heading = finiteNumber(pendingProps.opt("cameraHeading")) ?: camera.bearing
283 if (abs(camera.tilt - pitch) > 0.01 || abs(camera.bearing - heading) > 0.01) {
284 nativeMap.moveCamera(CameraUpdateFactory.newCameraPosition(CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build()))
285 }
286 }
287
288 private fun updateScale() {
289 val nativeMap = map ?: return
290 if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scale.visibility = View.GONE; return }
291 val pixels = 80 * resources.displayMetrics.density
292 val start = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f, height / 2f))
293 val end = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f + pixels, height / 2f))
294 val measured = FloatArray(1)
295 android.location.Location.distanceBetween(start.latitude, start.longitude, end.latitude, end.longitude, measured)
296 val meters = measured[0].toDouble()
297 if (!meters.isFinite() || meters <= 0) { scale.visibility = View.GONE; return }
298 val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(meters)))
299 val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= meters }?.times(magnitude) ?: magnitude
300 scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m"
301 scaleLine.layoutParams = LinearLayout.LayoutParams((pixels * length / meters).roundToInt().coerceAtLeast(1), 2)
302 scale.visibility = View.VISIBLE
303 }
304
305 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
306 super.onSizeChanged(width, height, oldWidth, oldHeight)
307 if (width > 0 && height > 0) { applyPadding(); updateScale(); if (initialCameraKey == null) applyPending() }
308 }
309
310 override fun onAttachedToWindow() {
311 super.onAttachedToWindow()
312 if (!destroyed && !lifecycleStarted) { mapView.onStart(); mapView.onResume(); lifecycleStarted = true }
313 }
314
315 override fun onDetachedFromWindow() {
316 if (lifecycleStarted) { mapView.onPause(); mapView.onStop(); lifecycleStarted = false }
317 super.onDetachedFromWindow()
318 }
319
320 fun lowMemory() { if (!destroyed) mapView.onLowMemory() }
321
322 fun destroy() {
323 if (destroyed) return
324 destroyed = true
325 styleVersion++
326 onMarkerTap = null; onMapPress = null; onRegionChangeComplete = null
327 markerHandlers.clear(); geometryPoints = emptyList()
328 if (lifecycleStarted) { mapView.onPause(); mapView.onStop(); lifecycleStarted = false }
329 mapView.onDestroy()
330 readyStyle = null; map = null
331 }
332
333 private fun coordinates(value: JSONArray?): List<LatLng> {
334 if (value == null) return emptyList()
335 return (0 until value.length() - 1 step 2).mapNotNull { index ->
336 val latitude = finiteNumber(value.opt(index)) ?: return@mapNotNull null
337 val longitude = finiteNumber(value.opt(index + 1)) ?: return@mapNotNull null
338 if (latitude in -90.0..90.0 && longitude in -180.0..180.0) LatLng(latitude, longitude) else null
339 }
340 }
341
342 private fun region(value: JSONObject?): LatLngBounds? {
343 val center = value?.optJSONObject("center") ?: return null
344 val span = value.optJSONObject("span") ?: return null
345 val latitude = finiteNumber(center.opt("latitude")) ?: return null
346 val longitude = finiteNumber(center.opt("longitude")) ?: return null
347 val height = finiteNumber(span.opt("latitudeDelta")) ?: finiteNumber(span.opt("latitude_delta")) ?: return null
348 val width = finiteNumber(span.opt("longitudeDelta")) ?: finiteNumber(span.opt("longitude_delta")) ?: return null
349 if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0 || height <= 0 || height > 180 || width <= 0 || width > 360 || latitude - height / 2 < -90 || latitude + height / 2 > 90) return null
350 return LatLngBounds.from(latitude + height / 2, longitude + width / 2, latitude - height / 2, longitude - width / 2)
351 }
352
353 private fun finiteNumber(value: Any?): Double? = (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
354}
packages/android/templates/MainActivityNative.kt.templatemodified+1-11
Changes to packages/android/templates/MainActivityNative.kt.template
@@ -110,17 +110,7 @@ import androidx.javascriptengine.Message
110110import androidx.javascriptengine.MessagePort
111111import androidx.javascriptengine.MessagePortClient
112112import com.craft.runtime.CraftNative
113import com.google.android.gms.maps.CameraUpdateFactory
114import com.google.android.gms.maps.GoogleMap
115import com.google.android.gms.maps.MapView
116import com.google.android.gms.maps.OnMapReadyCallback
117import com.google.android.gms.maps.model.BitmapDescriptorFactory
118import com.google.android.gms.maps.model.LatLng
119import com.google.android.gms.maps.model.LatLngBounds
120import com.google.android.gms.maps.model.Marker
121import com.google.android.gms.maps.model.MarkerOptions
122import com.google.android.gms.maps.model.Polyline
123import com.google.android.gms.maps.model.PolylineOptions
113
124114import org.json.JSONArray
125115import org.json.JSONObject
126116import java.io.File
packages/android/templates/build.gradle.kts.app.templatemodified+1-1
Changes to packages/android/templates/build.gradle.kts.app.template
@@ -99,7 +99,7 @@ dependencies {
9999 // Google Play Location Services (for geolocation)
100100 implementation("com.google.android.gms:play-services-location:21.1.0")
101101
102
102
103103
104104 // Google Play In-App Review
105105 implementation("com.google.android.play:review:2.0.1")