craft-native 0.0.238 → 0.0.241

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ /** A key-free native Android map. STX geometry never enters a WebView. */
2
+ internal 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 lifecycleResumed = false
8
+ private var lifecycleOwner: androidx.lifecycle.LifecycleOwner? = null
9
+ private val lifecycleObserver = object : androidx.lifecycle.DefaultLifecycleObserver {
10
+ override fun onStart(owner: androidx.lifecycle.LifecycleOwner) { startMap() }
11
+ override fun onResume(owner: androidx.lifecycle.LifecycleOwner) { resumeMap() }
12
+ override fun onPause(owner: androidx.lifecycle.LifecycleOwner) { pauseMap() }
13
+ override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { stopMap() }
14
+ }
15
+ private var pendingProps = JSONObject()
16
+ private var pendingChildren = JSONArray()
17
+ private var styleKey: String? = null
18
+ private var styleVersion = 0
19
+ private var readyStyle: Style? = null
20
+ private var initialCameraKey: String? = null
21
+ private var reportedRegion: LatLngBounds? = null
22
+ private var reportedCamera: CameraPosition? = null
23
+ private var reportedViewport: Pair<Int, Int>? = null
24
+ private var userChangingCamera = false
25
+ private var geometryPoints = emptyList<LatLng>()
26
+ private val markerHandlers = mutableMapOf<String, String>()
27
+ private var onMarkerTap: ((String) -> Unit)? = null
28
+ var onMapPress: ((LatLng) -> Unit)? = null
29
+ var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
30
+ private val attribution = TextView(context).apply {
31
+ textSize = 9f
32
+ setTextColor(Color.DKGRAY)
33
+ setBackgroundColor(Color.argb(230, 255, 255, 255))
34
+ maxLines = 2
35
+ }
36
+ private val scale = LinearLayout(context).apply {
37
+ orientation = LinearLayout.VERTICAL
38
+ setPadding(4, 2, 4, 2)
39
+ setBackgroundColor(Color.argb(230, 255, 255, 255))
40
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
41
+ visibility = View.GONE
42
+ }
43
+ private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) }
44
+ private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) }
45
+
46
+ init {
47
+ MapLibre.getInstance(context)
48
+ mapView = MapView(context)
49
+ addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
50
+ scale.addView(scaleLabel)
51
+ scale.addView(scaleLine, LinearLayout.LayoutParams(1, 2))
52
+ addView(scale, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
53
+ leftMargin = (8 * resources.displayMetrics.density).roundToInt()
54
+ bottomMargin = (24 * resources.displayMetrics.density).roundToInt()
55
+ })
56
+ addView(attribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply {
57
+ rightMargin = (8 * resources.displayMetrics.density).roundToInt()
58
+ bottomMargin = (8 * resources.displayMetrics.density).roundToInt()
59
+ })
60
+ mapView.onCreate(null)
61
+ mapView.addOnDidFinishLoadingMapListener { if (!destroyed && initialCameraKey == null) applyPending() }
62
+ mapView.getMapAsync(this)
63
+ }
64
+
65
+ override fun onMapReady(nativeMap: MapLibreMap) {
66
+ if (destroyed) return
67
+ map = nativeMap
68
+ nativeMap.addOnMapClickListener { point ->
69
+ val screen = nativeMap.projection.toScreenLocation(point)
70
+ val radius = 18 * resources.displayMetrics.density
71
+ 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()
72
+ val id = hit?.takeIf { it.hasProperty("craftMarkerId") }?.getStringProperty("craftMarkerId")
73
+ if (id != null) onMarkerTap?.invoke(id) else onMapPress?.invoke(point)
74
+ true
75
+ }
76
+ nativeMap.addOnCameraMoveStartedListener { reason ->
77
+ if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) userChangingCamera = true
78
+ }
79
+ nativeMap.addOnCameraMoveListener { updateScale() }
80
+ nativeMap.addOnCameraIdleListener {
81
+ updateScale()
82
+ if (userChangingCamera) {
83
+ userChangingCamera = false
84
+ val bounds = nativeMap.projection.visibleRegion.latLngBounds
85
+ val camera = nativeMap.cameraPosition
86
+ val center = camera.target
87
+ if (center != null) {
88
+ val event = JSONObject()
89
+ .put("center", JSONObject().put("latitude", center.latitude).put("longitude", center.longitude))
90
+ .put("span", JSONObject().put("latitudeDelta", bounds.latitudeNorth - bounds.latitudeSouth).put("longitudeDelta", bounds.longitudeEast - bounds.longitudeWest))
91
+ .put("heading", camera.bearing).put("pitch", camera.tilt)
92
+ reportedRegion = region(event)
93
+ reportedCamera = camera
94
+ reportedViewport = width to height
95
+ onRegionChangeComplete?.invoke(event)
96
+ }
97
+ }
98
+ }
99
+ applyPending()
100
+ }
101
+
102
+ fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
103
+ pendingProps = props
104
+ pendingChildren = children
105
+ onMarkerTap = markerHandler
106
+ applyPending()
107
+ }
108
+
109
+ fun markerHandler(id: String): String? = markerHandlers[id]
110
+
111
+ private fun applyPending() {
112
+ val nativeMap = map ?: return
113
+ if (destroyed) return
114
+ nativeMap.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
115
+ nativeMap.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
116
+ nativeMap.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
117
+ nativeMap.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
118
+ nativeMap.uiSettings.isTiltGesturesEnabled = craftNativeBoolean(pendingProps.opt("pitchEnabled"), true)
119
+ // The authored attribution is kept visible rather than hidden in a menu.
120
+ nativeMap.uiSettings.isAttributionEnabled = false
121
+ nativeMap.uiSettings.isLogoEnabled = false
122
+ applyPadding()
123
+ val nextStyle = basemapStyle()
124
+ if (styleKey != nextStyle) {
125
+ styleKey = nextStyle
126
+ readyStyle = null
127
+ val version = ++styleVersion
128
+ nativeMap.setStyle(Style.Builder().fromJson(nextStyle)) { loaded ->
129
+ if (!destroyed && version == styleVersion) {
130
+ readyStyle = loaded
131
+ installOverlays(loaded)
132
+ applyPending()
133
+ }
134
+ }
135
+ return
136
+ }
137
+ val loaded = readyStyle ?: return
138
+ updateOverlays(loaded)
139
+ applyLocation(nativeMap, loaded)
140
+ val explicit = region(pendingProps.optJSONObject("region"))
141
+ val initial = region(pendingProps.optJSONObject("initialRegion"))
142
+ if (explicit != null) {
143
+ // A measured region is a bounding envelope, especially with
144
+ // padding or rotation. Refitting its unchanged echo would zoom
145
+ // out after every gesture. New requests still move the camera.
146
+ if (!isCurrentReportedRegion(explicit)) moveCamera(explicit, 0)
147
+ initialCameraKey = null
148
+ } else if (initial != null) {
149
+ val key = "region:${pendingProps.optJSONObject("initialRegion")}"
150
+ if (initialCameraKey != key) { moveCamera(initial, 0); initialCameraKey = key }
151
+ } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
152
+ val key = "$width,$height:${pendingProps.optJSONObject("edgePadding")}:${pendingProps.opt("cameraPitch")}:${pendingProps.opt("cameraHeading")}:" + geometryPoints.joinToString(";") { "${it.latitude},${it.longitude}" }
153
+ if (initialCameraKey != key && geometryPoints.isNotEmpty() && width > 0 && height > 0) {
154
+ val distinct = geometryPoints.distinctBy { it.latitude to it.longitude }
155
+ if (distinct.size == 1) nativeMap.moveCamera(CameraUpdateFactory.newLatLngZoom(distinct.first(), 15.0))
156
+ else moveCamera(LatLngBounds.Builder().includes(geometryPoints).build(), if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt())
157
+ initialCameraKey = key
158
+ }
159
+ } else initialCameraKey = null
160
+ applyOrientation()
161
+ updateScale()
162
+ }
163
+
164
+ private fun basemapStyle(): String {
165
+ val custom = pendingProps.optJSONObject("tileOverlay")
166
+ val template = custom?.optString("urlTemplate").orEmpty()
167
+ val credit = custom?.optString("attribution").orEmpty()
168
+ val customValid = template.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { template.contains(it) } && credit.isNotBlank()
169
+ val type = pendingProps.optString("mapType", "standard").lowercase()
170
+ val satellite = type == "satellite" || type == "hybrid"
171
+ val url = when {
172
+ customValid -> template
173
+ satellite -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
174
+ type == "terrain" -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"
175
+ else -> "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
176
+ }
177
+ val attributionText = when {
178
+ customValid -> credit
179
+ satellite -> "Imagery © Esri, Maxar, Earthstar Geographics, and the GIS User Community"
180
+ type == "terrain" -> "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"
181
+ else -> "© OpenStreetMap contributors"
182
+ }
183
+ attribution.text = attributionText
184
+ val sources = JSONObject().put("basemap", rasterSource(url, attributionText))
185
+ val layers = JSONArray().put(JSONObject().put("id", "basemap").put("type", "raster").put("source", "basemap"))
186
+ if (!customValid && type == "hybrid") {
187
+ sources.put("labels", rasterSource("https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", "Labels © Esri"))
188
+ layers.put(JSONObject().put("id", "labels").put("type", "raster").put("source", "labels"))
189
+ attribution.text = "$attributionText · Labels © Esri"
190
+ }
191
+ return JSONObject().put("version", 8).put("sources", sources).put("layers", layers).toString()
192
+ }
193
+
194
+ private fun rasterSource(url: String, credit: String): JSONObject = JSONObject()
195
+ .put("type", "raster").put("tiles", JSONArray().put(url)).put("tileSize", 256).put("maxzoom", 19).put("attribution", credit)
196
+
197
+ private fun installOverlays(style: Style) {
198
+ style.addSource(GeoJsonSource("craft-map-overlays"))
199
+ style.addImage("craft-map-direction", directionBitmap())
200
+ style.addLayer(LineLayer("craft-map-routes", "craft-map-overlays")
201
+ .withFilter(Expression.eq(Expression.get("kind"), "line"))
202
+ .withProperties(lineColor(Expression.toColor(Expression.get("color"))), lineWidth(Expression.get("width")), lineCap("round"), lineJoin("round")))
203
+ style.addLayer(CircleLayer("craft-map-markers", "craft-map-overlays")
204
+ .withFilter(Expression.eq(Expression.get("kind"), "marker"))
205
+ .withProperties(circleColor(Expression.toColor(Expression.get("color"))), circleRadius(7f), circleStrokeColor(Color.WHITE), circleStrokeWidth(2f)))
206
+ style.addLayer(SymbolLayer("craft-map-directions", "craft-map-overlays")
207
+ .withFilter(Expression.eq(Expression.get("kind"), "direction"))
208
+ .withProperties(iconImage("craft-map-direction"), iconRotate(Expression.get("bearing")), iconRotationAlignment("map"), iconAllowOverlap(true), iconIgnorePlacement(true), iconAnchor("center")))
209
+ }
210
+
211
+ private fun updateOverlays(style: Style) {
212
+ val features = JSONArray()
213
+ val pointsForFit = mutableListOf<LatLng>()
214
+ markerHandlers.clear()
215
+ for (index in 0 until pendingChildren.length()) {
216
+ val child = pendingChildren.optJSONObject(index) ?: continue
217
+ val type = child.optString("type").lowercase()
218
+ val props = child.optJSONObject("props") ?: JSONObject()
219
+ val events = child.optJSONObject("events") ?: JSONObject()
220
+ val id = craftNativeOptionalString(props.opt("id")) ?: craftNativeOptionalString(child.opt("id")) ?: "overlay-$index"
221
+ val points = coordinates(props.optJSONArray("geometry"))
222
+ val properties = JSONObject().put("craftOverlayId", id)
223
+ val geometry = JSONObject()
224
+ if ((type == "mapmarker" || type == "marker") && points.isNotEmpty()) {
225
+ val point = points.first()
226
+ val bearing = finiteNumber(props.opt("bearing"))
227
+ properties.put("kind", if (bearing == null) "marker" else "direction").put("craftMarkerId", id)
228
+ properties.put("bearing", (bearing ?: 0.0) % 360).put("color", cssColor(props.opt("pinColor"), Color.rgb(36, 114, 75)))
229
+ geometry.put("type", "Point").put("coordinates", JSONArray().put(point.longitude).put(point.latitude))
230
+ listOf(events.opt("onPress"), events.opt("onMarkerPress")).firstNotNullOfOrNull(::craftNativeOptionalString)?.let { markerHandlers[id] = it }
231
+ pointsForFit.add(point)
232
+ } else if ((type == "mappolyline" || type == "polyline") && points.size > 1) {
233
+ val authoredWidth = props.opt("strokeWidth")
234
+ val width = if (authoredWidth == null || authoredWidth == JSONObject.NULL) 3.0 else finiteNumber(authoredWidth) ?: continue
235
+ if (width <= 0 || !width.toFloat().isFinite()) continue
236
+ properties.put("kind", "line").put("color", cssColor(props.opt("strokeColor"), Color.BLUE)).put("width", width)
237
+ geometry.put("type", "LineString").put("coordinates", JSONArray().apply { points.forEach { put(JSONArray().put(it.longitude).put(it.latitude)) } })
238
+ pointsForFit.addAll(points)
239
+ } else continue
240
+ features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry))
241
+ }
242
+ geometryPoints = pointsForFit
243
+ style.getSourceAs<GeoJsonSource>("craft-map-overlays")?.setGeoJson(JSONObject().put("type", "FeatureCollection").put("features", features).toString())
244
+ }
245
+
246
+ private fun cssColor(value: Any?, fallback: Int): String {
247
+ val color = (value as? String)?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback
248
+ return "rgba(${Color.red(color)},${Color.green(color)},${Color.blue(color)},${Color.alpha(color) / 255.0})"
249
+ }
250
+
251
+ private fun directionBitmap(): Bitmap {
252
+ val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
253
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
254
+ bitmap.density = resources.displayMetrics.densityDpi
255
+ val canvas = Canvas(bitmap)
256
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
257
+ paint.color = Color.WHITE
258
+ canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
259
+ paint.color = Color.rgb(36, 114, 75)
260
+ canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint)
261
+ paint.color = Color.WHITE
262
+ paint.textSize = size * 0.76f
263
+ paint.textAlign = Paint.Align.CENTER
264
+ paint.typeface = Typeface.DEFAULT_BOLD
265
+ canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint)
266
+ return bitmap
267
+ }
268
+
269
+ private fun applyLocation(nativeMap: MapLibreMap, style: Style) {
270
+ val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
271
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
272
+ val enabled = permitted && craftNativeBoolean(pendingProps.opt("showsUserLocation"))
273
+ val component = nativeMap.locationComponent
274
+ if (!enabled && !component.isLocationComponentActivated) return
275
+ try {
276
+ if (!component.isLocationComponentActivated) component.activateLocationComponent(LocationComponentActivationOptions.builder(context, style).useDefaultLocationEngine(true).build())
277
+ component.isLocationComponentEnabled = enabled
278
+ } catch (error: SecurityException) {
279
+ // Permission may be revoked between the check and native activation.
280
+ if (component.isLocationComponentActivated) component.isLocationComponentEnabled = false
281
+ }
282
+ }
283
+
284
+ private fun applyPadding() {
285
+ val value = pendingProps.optJSONObject("edgePadding")
286
+ fun inset(key: String): Double = (finiteNumber(value?.opt(key)) ?: 0.0).coerceIn(0.0, 10_000.0) * resources.displayMetrics.density
287
+ var left = inset("left"); var right = inset("right"); var top = inset("top"); var bottom = inset("bottom")
288
+ val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f)
289
+ val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f)
290
+ val horizontal = left + right; val vertical = top + bottom
291
+ if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
292
+ if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
293
+ map?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
294
+ // Floating STX controls reserve the same space for credits and scale
295
+ // as for route fitting, so a profile never covers either utility.
296
+ (attribution.layoutParams as LayoutParams).let { params ->
297
+ val margin = bottom.roundToInt() + (8 * resources.displayMetrics.density).roundToInt()
298
+ if (params.bottomMargin != margin) { params.bottomMargin = margin; attribution.layoutParams = params }
299
+ }
300
+ (scale.layoutParams as LayoutParams).let { params ->
301
+ val margin = bottom.roundToInt() + (32 * resources.displayMetrics.density).roundToInt()
302
+ if (params.bottomMargin != margin) { params.bottomMargin = margin; scale.layoutParams = params }
303
+ }
304
+ }
305
+
306
+ private fun moveCamera(bounds: LatLngBounds, padding: Int) {
307
+ post {
308
+ val nativeMap = map
309
+ if (!destroyed && nativeMap != null && width > 0 && height > 0) {
310
+ val camera = nativeMap.cameraPosition
311
+ val bearing = finiteNumber(pendingProps.opt("cameraHeading")) ?: camera.bearing
312
+ val tilt = finiteNumber(pendingProps.opt("cameraPitch"))?.coerceIn(0.0, 60.0) ?: camera.tilt
313
+ val insets = nativeMap.padding.map { it + padding }.toIntArray()
314
+ val target = nativeMap.getCameraForLatLngBounds(bounds, insets, bearing, tilt)
315
+ if (target == null) initialCameraKey = null
316
+ else nativeMap.moveCamera(CameraUpdateFactory.newCameraPosition(target))
317
+ }
318
+ }
319
+ }
320
+
321
+ private fun isCurrentReportedRegion(requested: LatLngBounds): Boolean {
322
+ val reported = reportedRegion ?: return false
323
+ val measured = reportedCamera ?: return false
324
+ val current = map?.cameraPosition ?: return false
325
+ val measuredTarget = measured.target ?: return false
326
+ val currentTarget = current.target ?: return false
327
+ fun same(a: Double, b: Double): Boolean = abs(a - b) < 0.000000001
328
+ return reportedViewport == (width to height) &&
329
+ same(requested.latitudeNorth, reported.latitudeNorth) && same(requested.latitudeSouth, reported.latitudeSouth) &&
330
+ same(requested.longitudeEast, reported.longitudeEast) && same(requested.longitudeWest, reported.longitudeWest) &&
331
+ abs(current.zoom - measured.zoom) < 0.000001 &&
332
+ same(currentTarget.latitude, measuredTarget.latitude) && same(currentTarget.longitude, measuredTarget.longitude)
333
+ }
334
+
335
+ private fun applyOrientation() {
336
+ val nativeMap = map ?: return
337
+ val camera = nativeMap.cameraPosition
338
+ val pitch = finiteNumber(pendingProps.opt("cameraPitch"))?.coerceIn(0.0, 60.0) ?: camera.tilt
339
+ val heading = finiteNumber(pendingProps.opt("cameraHeading")) ?: camera.bearing
340
+ if (abs(camera.tilt - pitch) > 0.01 || abs(camera.bearing - heading) > 0.01) {
341
+ nativeMap.moveCamera(CameraUpdateFactory.newCameraPosition(CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build()))
342
+ }
343
+ }
344
+
345
+ private fun updateScale() {
346
+ val nativeMap = map ?: return
347
+ if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scale.visibility = View.GONE; return }
348
+ val pixels = 80 * resources.displayMetrics.density
349
+ val start = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f, height / 2f))
350
+ val end = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f + pixels, height / 2f))
351
+ val measured = FloatArray(1)
352
+ android.location.Location.distanceBetween(start.latitude, start.longitude, end.latitude, end.longitude, measured)
353
+ val meters = measured[0].toDouble()
354
+ if (!meters.isFinite() || meters <= 0) { scale.visibility = View.GONE; return }
355
+ val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(meters)))
356
+ val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= meters }?.times(magnitude) ?: magnitude
357
+ scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m"
358
+ scaleLine.layoutParams = LinearLayout.LayoutParams((pixels * length / meters).roundToInt().coerceAtLeast(1), 2)
359
+ scale.visibility = View.VISIBLE
360
+ }
361
+
362
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
363
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
364
+ if (width > 0 && height > 0) applyPending()
365
+ }
366
+
367
+ override fun onAttachedToWindow() {
368
+ super.onAttachedToWindow()
369
+ if (destroyed) return
370
+ lifecycleOwner = context as? androidx.lifecycle.LifecycleOwner
371
+ val owner = lifecycleOwner
372
+ if (owner != null) owner.lifecycle.addObserver(lifecycleObserver)
373
+ else { startMap(); resumeMap() }
374
+ }
375
+
376
+ override fun onDetachedFromWindow() {
377
+ lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver)
378
+ lifecycleOwner = null
379
+ stopMap()
380
+ super.onDetachedFromWindow()
381
+ }
382
+
383
+ private fun startMap() {
384
+ if (!destroyed && !lifecycleStarted) { mapView.onStart(); lifecycleStarted = true }
385
+ }
386
+
387
+ private fun resumeMap() {
388
+ startMap()
389
+ if (!destroyed && !lifecycleResumed) { mapView.onResume(); lifecycleResumed = true }
390
+ }
391
+
392
+ private fun pauseMap() {
393
+ if (lifecycleResumed) { mapView.onPause(); lifecycleResumed = false }
394
+ }
395
+
396
+ private fun stopMap() {
397
+ pauseMap()
398
+ if (lifecycleStarted) { mapView.onStop(); lifecycleStarted = false }
399
+ }
400
+
401
+ fun lowMemory() { if (!destroyed) mapView.onLowMemory() }
402
+
403
+ fun destroy() {
404
+ if (destroyed) return
405
+ destroyed = true
406
+ styleVersion++
407
+ onMarkerTap = null; onMapPress = null; onRegionChangeComplete = null
408
+ markerHandlers.clear(); geometryPoints = emptyList()
409
+ lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver)
410
+ lifecycleOwner = null
411
+ stopMap()
412
+ mapView.onDestroy()
413
+ readyStyle = null; map = null
414
+ reportedRegion = null; reportedCamera = null; reportedViewport = null
415
+ }
416
+
417
+ private fun coordinates(value: JSONArray?): List<LatLng> {
418
+ if (value == null) return emptyList()
419
+ return (0 until value.length() - 1 step 2).mapNotNull { index ->
420
+ val latitude = finiteNumber(value.opt(index)) ?: return@mapNotNull null
421
+ val longitude = finiteNumber(value.opt(index + 1)) ?: return@mapNotNull null
422
+ if (latitude in -90.0..90.0 && longitude in -180.0..180.0) LatLng(latitude, longitude) else null
423
+ }
424
+ }
425
+
426
+ private fun region(value: JSONObject?): LatLngBounds? {
427
+ val center = value?.optJSONObject("center") ?: return null
428
+ val span = value.optJSONObject("span") ?: return null
429
+ val latitude = finiteNumber(center.opt("latitude")) ?: return null
430
+ val longitude = finiteNumber(center.opt("longitude")) ?: return null
431
+ val height = finiteNumber(span.opt("latitudeDelta")) ?: finiteNumber(span.opt("latitude_delta")) ?: return null
432
+ val width = finiteNumber(span.opt("longitudeDelta")) ?: finiteNumber(span.opt("longitude_delta")) ?: return null
433
+ 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
434
+ return LatLngBounds.from(latitude + height / 2, longitude + width / 2, latitude - height / 2, longitude - width / 2)
435
+ }
436
+
437
+ private fun finiteNumber(value: Any?): Double? = (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
438
+ }
@@ -8,6 +8,7 @@ import android.content.Intent
8
8
  import android.os.Build
9
9
  import androidx.core.app.NotificationCompat
10
10
  import androidx.core.app.NotificationManagerCompat
11
+ import androidx.core.content.ContextCompat
11
12
  import com.google.firebase.messaging.FirebaseMessagingService
12
13
  import com.google.firebase.messaging.RemoteMessage
13
14
  import org.json.JSONObject
@@ -59,7 +60,8 @@ class CraftNativeMessagingService : FirebaseMessagingService() {
59
60
  val id = (data["notification_id"]?.toIntOrNull() ?: System.currentTimeMillis().toInt())
60
61
  val tap = PendingIntent.getActivity(this, id, open, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
61
62
  val built = NotificationCompat.Builder(this, CHANNEL)
62
- .setSmallIcon(applicationInfo.icon)
63
+ .setSmallIcon(R.drawable.craft_notification_icon)
64
+ .setColor(ContextCompat.getColor(this, R.color.craft_notification_color))
63
65
  .setContentTitle(title)
64
66
  .setContentText(body)
65
67
  .setStyle(NotificationCompat.BigTextStyle().bigText(body))
@@ -32,7 +32,8 @@ internal class CraftNativeNotificationReceiver : BroadcastReceiver() {
32
32
  ))
33
33
  }
34
34
  val notification = NotificationCompat.Builder(context, "craft_notifications")
35
- .setSmallIcon(android.R.drawable.ic_dialog_info)
35
+ .setSmallIcon(R.drawable.craft_notification_icon)
36
+ .setColor(ContextCompat.getColor(context, R.color.craft_notification_color))
36
37
  .setContentTitle(value.optString("title"))
37
38
  .setContentText(value.optString("body"))
38
39
  .setPriority(NotificationCompat.PRIORITY_DEFAULT)
@@ -12,6 +12,7 @@ import android.os.IBinder
12
12
  import android.os.Looper
13
13
  import androidx.core.app.NotificationCompat
14
14
  import androidx.core.content.ContextCompat
15
+ import {{PACKAGE_NAME}}.R
15
16
  import com.google.android.gms.location.LocationCallback
16
17
  import com.google.android.gms.location.LocationRequest
17
18
  import com.google.android.gms.location.LocationResult
@@ -287,7 +288,8 @@ class LocationRecordingService : Service() {
287
288
  manager.createNotificationChannel(NotificationChannel(CHANNEL, "Activity recording", NotificationManager.IMPORTANCE_LOW))
288
289
  }
289
290
  val notification = NotificationCompat.Builder(this, CHANNEL)
290
- .setSmallIcon(android.R.drawable.ic_menu_mylocation)
291
+ .setSmallIcon(R.drawable.craft_notification_icon)
292
+ .setColor(ContextCompat.getColor(this, R.color.craft_notification_color))
291
293
  .setContentTitle("Recording your activity")
292
294
  .setContentText("${applicationInfo.loadLabel(packageManager)} is securely saving your route")
293
295
  .setOngoing(true)