craft-native 0.0.237 → 0.0.241

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +71 -0
  2. package/dist/android/src/emulator.d.ts +23 -0
  3. package/dist/android/src/index.d.ts +24 -2
  4. package/dist/android/src/index.js +362 -103
  5. package/dist/android/src/native-map.d.ts +7 -0
  6. package/dist/android/templates/CraftBridge.kt.template +67 -55
  7. package/dist/android/templates/CraftNative.kt.template +27 -0
  8. package/dist/android/templates/CraftNativeCapabilities.kt.template +36 -5
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +440 -0
  10. package/dist/android/templates/CraftNativeMapLibre.kt.template +438 -0
  11. package/dist/android/templates/CraftNativeMessagingService.kt.template +3 -1
  12. package/dist/android/templates/CraftNativeNotificationReceiver.kt.template +2 -1
  13. package/dist/android/templates/CraftNativeServices.kt.template +45 -35
  14. package/dist/android/templates/CraftNativeShell.kt.template +103 -20
  15. package/dist/android/templates/LocationRecordingService.kt.template +255 -47
  16. package/dist/android/templates/MainActivityNative.kt.template +7 -428
  17. package/dist/android/templates/build.gradle.kts.app.template +1 -1
  18. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  19. package/dist/android-adb.d.ts +2 -0
  20. package/dist/api/index.d.ts +1 -1
  21. package/dist/api/mobile.d.ts +49 -14
  22. package/dist/browser/storage.d.ts +3 -0
  23. package/dist/browser.js +163 -51
  24. package/dist/cli.js +1 -1
  25. package/dist/index.cjs +126 -12
  26. package/dist/index.js +126 -12
  27. package/dist/ios/src/index.d.ts +5 -0
  28. package/dist/ios/src/index.js +17 -1
  29. package/dist/ios/src/testing.d.ts +16 -2
  30. package/dist/ios/templates/CraftApp.swift +418 -93
  31. package/dist/ios/templates/CraftHeartRate.swift +141 -44
  32. package/dist/ios/templates/CraftNativeActions.swift +46 -48
  33. package/dist/ios/templates/CraftNativeScreen.swift +82 -17
  34. package/dist/ios/templates/CraftPersistentStorage.swift +99 -0
  35. package/dist/ios/templates/CraftRecordingArchive.swift +85 -0
  36. package/dist/ios/templates/testing/DriverTests.swift.template +45 -1
  37. package/dist/macos/templates/native_layout.zig +41 -5
  38. package/dist/mobile-testing.js +30 -4
  39. package/dist/mobile.js +169 -17
  40. package/package.json +1 -1
@@ -0,0 +1,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)
@@ -1,5 +1,7 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
+ import com.craft.runtime.CraftNative
4
+
3
5
  import android.Manifest
4
6
  import android.app.Activity
5
7
  import android.content.ActivityNotFoundException
@@ -29,6 +31,15 @@ import java.util.Locale
29
31
  import java.util.UUID
30
32
  {{NATIVE_PUSH_IMPORTS}}
31
33
 
34
+ private fun craftLocationFix(location: android.location.Location): JSONObject = JSONObject()
35
+ .put("latitude", location.latitude)
36
+ .put("longitude", location.longitude)
37
+ .put("altitude", location.altitude)
38
+ .put("accuracy", location.accuracy.toDouble())
39
+ .put("speed", location.speed.toDouble())
40
+ .put("heading", location.bearing.toDouble())
41
+ .put("timestamp", location.time.toDouble())
42
+
32
43
  /**
33
44
  * The capabilities a native screen asks for beyond Craft's core set
34
45
  * (CraftNativeCapabilities): the system's dialogs, the in-app browser, the
@@ -59,6 +70,16 @@ internal class CraftNativeServices(
59
70
  private val speechQueue = mutableListOf<() -> Unit>()
60
71
  private val speeches = mutableMapOf<String, (CraftNativeCapabilityResult) -> Unit>()
61
72
 
73
+ init {
74
+ if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
75
+ // Native screens must also hear a recording started by the page.
76
+ // Keep this process sink independent from the page's listener.
77
+ com.craft.runtime.CraftLocationRecordingStore.nativeListener = { location, id ->
78
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", craftLocationFix(location).put("recordingId", id)))
79
+ }
80
+ }
81
+ }
82
+
62
83
  /** `background.complete(ok, id)`: who is waiting for a background refresh's answer. */
63
84
  var onBackgroundComplete: ((Boolean, String?) -> Unit)? = null
64
85
 
@@ -84,7 +105,11 @@ internal class CraftNativeServices(
84
105
  add("healthAvailability")
85
106
  }
86
107
  if (config.optBoolean("enableKeepAwake")) add("keepAwake")
87
- if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) add("geolocation")
108
+ if (config.optBoolean("enableGeolocation") || config.optBoolean("enableBackgroundLocation")) {
109
+ add("geolocation")
110
+ if (CraftNative.supportsRecordingOwnership()) add("locationRecordingOwnership")
111
+ if (CraftNative.supportsRecordingIntentIdentity()) add("locationRecordingIntentIdentity")
112
+ }
88
113
  if (config.optJSONObject("backgroundRefresh")?.optBoolean("enabled") == true || config.optBoolean("enablePushNotifications")) add("background")
89
114
  // craft.crashReporter: the app's crash and error reporter (bughq).
90
115
  if (CraftCrashReporter.enabled) add("crashReporter")
@@ -130,6 +155,8 @@ internal class CraftNativeServices(
130
155
  "CrashReporter" -> crashReporter(method, args, done)
131
156
  else -> done(failure("UNKNOWN_ACTION", "Unsupported native API"))
132
157
  }
158
+ } catch (error: com.craft.runtime.CraftRecordingException) {
159
+ done(failure(error.code, error.message ?: "Recording failed"))
133
160
  } catch (error: Exception) {
134
161
  Log.e("CraftNativeAndroid", "$module.$method failed", error)
135
162
  done(failure("CRAFT_ERROR", error.message ?: "$module.$method failed"))
@@ -359,14 +386,6 @@ internal class CraftNativeServices(
359
386
  private var watchCallback: com.google.android.gms.location.LocationCallback? = null
360
387
  private var pendingLocationPermission: ((Boolean) -> Unit)? = null
361
388
 
362
- private fun fix(location: android.location.Location): JSONObject = JSONObject()
363
- .put("latitude", location.latitude)
364
- .put("longitude", location.longitude)
365
- .put("altitude", location.altitude)
366
- .put("accuracy", location.accuracy.toDouble())
367
- .put("speed", location.speed.toDouble())
368
- .put("heading", location.bearing.toDouble())
369
- .put("timestamp", location.time.toDouble())
370
389
 
371
390
  private fun hasLocation(): Boolean =
372
391
  ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
@@ -374,10 +393,15 @@ internal class CraftNativeServices(
374
393
 
375
394
  /** Location, asked for once a person starts something that needs it. */
376
395
  private fun withLocation(done: (CraftNativeCapabilityResult) -> Unit, then: () -> Unit) {
377
- if (hasLocation()) return then()
396
+ val authorized = {
397
+ try { then() }
398
+ catch (error: com.craft.runtime.CraftRecordingException) { done(failure(error.code, error.message ?: "Recording failed")) }
399
+ catch (error: Exception) { done(failure("CRAFT_ERROR", error.message ?: "Location failed")) }
400
+ }
401
+ if (hasLocation()) return authorized()
378
402
  val host = activity() ?: return done(failure("PERMISSION_DENIED", "Location is not allowed"))
379
403
  pendingLocationPermission?.invoke(false)
380
- pendingLocationPermission = { granted -> if (granted) then() else done(failure("PERMISSION_DENIED", "Location is not allowed")) }
404
+ pendingLocationPermission = { granted -> if (granted) authorized() else done(failure("PERMISSION_DENIED", "Location is not allowed")) }
381
405
  ActivityCompat.requestPermissions(host, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), LOCATION_PERMISSION_REQUEST)
382
406
  }
383
407
 
@@ -396,7 +420,7 @@ internal class CraftNativeServices(
396
420
  val priority = if (options.optBoolean("enableHighAccuracy", true)) com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY
397
421
  else com.google.android.gms.location.Priority.PRIORITY_BALANCED_POWER_ACCURACY
398
422
  locationClient.getCurrentLocation(priority, null)
399
- .addOnSuccessListener { location -> done(if (location != null) success(fix(location)) else failure("POSITION_UNAVAILABLE", "No location fix")) }
423
+ .addOnSuccessListener { location -> done(if (location != null) success(craftLocationFix(location)) else failure("POSITION_UNAVAILABLE", "No location fix")) }
400
424
  .addOnFailureListener { error -> done(failure("POSITION_UNAVAILABLE", error.message ?: "No location fix")) }
401
425
  }
402
426
  "watchPosition" -> withLocation(done) {
@@ -404,7 +428,7 @@ internal class CraftNativeServices(
404
428
  val callback = object : com.google.android.gms.location.LocationCallback() {
405
429
  override fun onLocationResult(result: com.google.android.gms.location.LocationResult) {
406
430
  for (location in result.locations) {
407
- CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", fix(location)))
431
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", craftLocationFix(location)))
408
432
  }
409
433
  }
410
434
  }
@@ -418,29 +442,15 @@ internal class CraftNativeServices(
418
442
  watchCallback = null
419
443
  done(success(true))
420
444
  }
421
- "startRecording" -> withLocation(done) {
422
- val options = args.optJSONObject(0) ?: JSONObject()
423
- val id = options.optString("id").ifBlank { UUID.randomUUID().toString() }
424
- store.start(context, id, System.currentTimeMillis())
425
- store.listener = { location -> CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "craftLocationUpdate").put("detail", fix(location))) }
426
- val intent = Intent(context, com.craft.runtime.LocationRecordingService::class.java).setAction(com.craft.runtime.LocationRecordingService.ACTION_START)
427
- runCatching { ContextCompat.startForegroundService(context, intent) }
428
- .onFailure { store.stop(context) }
429
- done(success(store.state(context)))
430
- }
431
- "pauseRecording", "resumeRecording" -> {
432
- store.setPaused(context, method == "pauseRecording")
433
- done(success(store.state(context)))
434
- }
435
- "stopRecording" -> {
436
- val state = store.state(context, includeLocations = true)
437
- store.stop(context)
438
- store.listener = null
439
- runCatching { context.startService(Intent(context, com.craft.runtime.LocationRecordingService::class.java).setAction(com.craft.runtime.LocationRecordingService.ACTION_STOP)) }
440
- done(success(state.put("active", false).put("paused", false)))
445
+ "startRecording", "pauseRecording", "resumeRecording", "stopRecording", "getRecordingState", "readRecording", "acknowledgeRecording" -> {
446
+ val invoke = {
447
+ val answer = JSONObject(store.perform(context, method, (args.optJSONObject(0) ?: JSONObject()).toString()))
448
+ if (answer.optBoolean("ok")) {
449
+ done(success(answer.opt("data")))
450
+ } else done(failure(answer.optString("code"), answer.optString("message")))
451
+ }
452
+ if (method == "startRecording") withLocation(done, invoke) else invoke()
441
453
  }
442
- "getRecordingState" -> done(success(store.state(context)))
443
- "readRecording" -> done(success(store.locations(context)))
444
454
  else -> done(failure("UNKNOWN_ACTION", "Unsupported Location method"))
445
455
  }
446
456
  }