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
.github/workflows/mobile-e2e.ymlmodified+2-2
Changes to .github/workflows/mobile-e2e.yml
@@ -76,7 +76,7 @@ on:
7676 android_suite:
7777 description: Android native fixture coverage for the android-navigation scope
7878 type: choice
79 options: [focused, full, system-bars]
79 options: [focused, full, system-bars, maps, accessibility]
8080 default: focused
8181 selectors:
8282 description: Optional XCTest selector for focused native iOS rendering or navigation
@@ -618,7 +618,7 @@ jobs:
618618 api-level: ${{ fromJSON((inputs.scope != 'android-navigation' || inputs.android_suite == 'full') && '[34,35]' || inputs.android_suite == 'system-bars' && '[35]' || '[34]') }}
619619 env:
620620 CRAFT_ANDROID_NATIVE_SUITE: ${{ inputs.scope != 'android-navigation' && 'full' || inputs.android_suite || 'focused' }}
621 CRAFT_ANDROID_TEST_CLASS: ${{ inputs.scope == 'android-navigation' && inputs.android_suite == 'system-bars' && 'dev.craft.navigationtest.NativeNavigationTest#nativeScreenBackgroundOptionAppliesAndResets' || inputs.scope == 'android-navigation' && inputs.android_suite != 'full' && 'dev.craft.navigationtest.NativeNavigationTest#outgoingTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#thirdTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#cancelledSwipeTargetIsVisibleWhenSelectedLater,dev.craft.navigationtest.NativeNavigationTest#completedReverseSwipeKeepsRetainedTabVisible,dev.craft.navigationtest.NativeNavigationTest#nativePullToRefreshSurvivesViewReattachment,dev.craft.navigationtest.NativeNavigationTest#pushedScreenTabAccessoryOpensNativeRouteAndClearsOnBack,dev.craft.navigationtest.NativeFlatListTest#refreshAppearanceUsesTheScreenContrastFallback,dev.craft.navigationtest.NativeNavigationTest#nativeDatePickerKeepsFollowingControlsMounted' || '' }}
621 CRAFT_ANDROID_TEST_CLASS: ${{ inputs.scope == 'android-navigation' && inputs.android_suite == 'accessibility' && 'dev.craft.navigationtest.NativeNavigationTest#nativeTestIdsRemainQueryableWithoutReplacingVisibleLabels' || inputs.scope == 'android-navigation' && inputs.android_suite == 'maps' && 'dev.craft.navigationtest.NativeMapTest#keyFreeBasemapsKeepTheRecordedRouteAndDirectionSignsVisible,dev.craft.navigationtest.NativeMapTest#nativeMapMutationsUpdateThePinAndCameraWithoutReloadingTheStyle,dev.craft.navigationtest.NativeMapTest#showingTheLocationDotDoesNotRequestPermissionDuringRendering,dev.craft.navigationtest.NativeMapTest#controlledRegionEchoPreservesTheCameraAfterANativeDrag,dev.craft.navigationtest.NativeMapTest#backgroundAndResumeKeepTheNativeMapAndCamera' || inputs.scope == 'android-navigation' && inputs.android_suite == 'system-bars' && 'dev.craft.navigationtest.NativeNavigationTest#nativeScreenBackgroundOptionAppliesAndResets' || inputs.scope == 'android-navigation' && inputs.android_suite != 'full' && 'dev.craft.navigationtest.NativeNavigationTest#outgoingTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#thirdTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#cancelledSwipeTargetIsVisibleWhenSelectedLater,dev.craft.navigationtest.NativeNavigationTest#completedReverseSwipeKeepsRetainedTabVisible,dev.craft.navigationtest.NativeNavigationTest#nativePullToRefreshSurvivesViewReattachment,dev.craft.navigationtest.NativeNavigationTest#pushedScreenTabAccessoryOpensNativeRouteAndClearsOnBack,dev.craft.navigationtest.NativeFlatListTest#refreshAppearanceUsesTheScreenContrastFallback,dev.craft.navigationtest.NativeNavigationTest#nativeDatePickerKeepsFollowingControlsMounted' || '' }}
622622 if: ${{ inputs.scope != 'ios-navigation' && inputs.scope != 'ios-render' }}
623623 runs-on: ubuntu-latest
624624 timeout-minutes: 35
docs/RELEASING.mdmodified+9-7
Changes to docs/RELEASING.md
@@ -18,7 +18,7 @@ Do not run the hook again after it has written the section.
1818The tag starts [Releaser](../.github/workflows/release.yml); pushing a tag is
1919the publication trigger, not proof that the release finished.
2020
21Both release and CI setup pin Pantry CLI 0.11.64 as well as the Pantry action
21Both release and CI setup pin Pantry CLI 0.11.93 as well as the Pantry action
2222revision; leaving the CLI at `latest` would add a GitHub API lookup before the
2323build can even start.
2424
@@ -38,7 +38,9 @@ publishes the GitHub release. Only then does a separate job publish the Zig
3838package to pantry and notify the registry to index the complete release.
3939Discord is notified only after the complete release is public, never from an
4040individual platform's staging job.
41The npm job separately scans and publishes the public JavaScript packages.
41The npm preparation job builds and scans the exact public JavaScript archives.
42The npm publication job waits for the verified GitHub release to become public
43and publishes those scanned archives without rebuilding them.
4244
4345Watch the entire Releaser run, not just the release page. Confirm its final
4446status is green, the page contains all four `craft-*.zip` archives plus the
@@ -54,8 +56,8 @@ publishing the incomplete draft. Do not interpret a successful tag push or a
5456partial set of staged archives as a completed release.
5557
5658The GitHub release is gated as a unit; the underlying package registries do not
57offer a cross-registry transaction. The Zig package waits for the completed
58GitHub release, but npm publication is deliberately independent and may become
59visible earlier. The final workflow status remains the release signal. If the
60post-release Zig publication fails, the GitHub release still contains the full
61verified archive set; rerun that failed job rather than republishing the draft.
59offer a cross-registry transaction. Both Zig and npm publication wait for the completed GitHub release. Either
60registry publication can still fail after the native archives become public, so
61the final workflow status remains the release signal. If a post-release registry
62publication fails, the GitHub release still contains the full verified archive
63set; inspect and rerun the failed job rather than republishing the draft.
docs/guides/native-stx.mdmodified+15-6
Changes to docs/guides/native-stx.md
@@ -154,7 +154,7 @@ next route.
154154| `Image` | `UIImageView` | `ImageView` |
155155| `ImageBackground` | `CraftNativeImageBackgroundView` | `CraftNativeImageBackgroundView` |
156156| `Video` | `CraftNativeVideoView` (`AVPlayerViewController`) | `CraftNativeVideoView` (`VideoView`) |
157| `Map` | `CraftNativeMapView` (`MKMapView`) | `CraftNativeMapView` (`GoogleMap`) |
157| `Map` | `CraftNativeMapView` (`MKMapView`) | `CraftNativeMapView` (native MapLibre or Google Maps) |
158158| `MapPolyline`, `MapMarker` | overlays inside `CraftNativeMapView` | overlays inside `CraftNativeMapView` |
159159| `Svg` | `CraftNativeSvgView` | `CraftNativeSvgView` |
160160| `G`, `SvgText` | drawn inside `CraftNativeSvgView` | drawn inside `CraftNativeSvgView` |
@@ -228,12 +228,21 @@ or `fitToBounds`, which recenters only when its overlay geometry changes. Explic
228228authored camera bounds on both hosts; only `fitToBounds` adds framing padding. A marker's `onPress` or
229229`onMarkerPress` receives `nativeEvent.id`; a marker's own handler wins, and the map-level handler is
230230used as a fallback when the marker has no non-empty handler.
231It is backed by MapKit on iOS and the Google Maps SDK on
232Android; the Android template adds that dependency only for screens that opt
233into the native map host. The shared `mapType` values are `standard`,
231It is backed by MapKit on iOS and the configured native provider on Android.
232Set the Craft Android config's `mapProvider` to `maplibre` for a key-free native
233GL view; `google` preserves the existing Google Maps host. Only the selected
234provider's SDK is included in the generated Android project. These STX map
235components do not use the DOM-based `@craft-native/ts-maps` adapter.
236The shared `mapType` values are `standard`,
234237`satellite`, and `hybrid`; Android's terrain-only style is intentionally not
235part of the cross-platform contract. Tile styling, clustering, directions, and
236offline tiles remain outside this contract. Set `showsUserLocation` to opt into the
238part of the cross-platform contract. `tileOverlay` accepts an HTTPS raster URL
239template and its visible provider attribution. `edgePadding` reserves logical
240points around a fitted route for floating controls. `pitchEnabled` controls
241tilt gestures; `cameraPitch` and `cameraHeading` set the camera explicitly.
242Markers accept `pinColor` for trailheads and inspected profile positions, or
243`bearing` for a contrasting route-direction sign. Clustering, turn-by-turn
244directions, and offline tile downloads remain outside this view contract.
245Set `showsUserLocation` to opt into the
237246platform location dot; the host only enables it when the app's existing
238247location permission is already granted, and never prompts from a renderer
239248pass. Android also forwards `MapView` start/stop lifecycle callbacks as map
packages/android/README.mdmodified+15-5
Changes to packages/android/README.md
@@ -147,18 +147,28 @@ craft android build --native-bundle ../native-screen.js
147147Native mode maps STX `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`,
148148`RefreshControl`, `Text`, `Button`, `Link`, `Icon`,
149149`Pressable`, `TouchableOpacity`, `TouchableHighlight`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Modal`, `Switch`, `Slider`, `ActivityIndicator`, `Image`, `ImageBackground`, `Video`, `Svg`, `G`, `SvgText`, `ScrollView`,
150`Map`, `SectionList`, and `FlatList` to Android views. `Map` uses the Google
151Maps SDK for trail polylines and marker taps; its overlays carry flat
150`Map`, `SectionList`, and `FlatList` to Android views. `Map` uses the configured
151native map provider for trail polylines and marker taps; its overlays carry flat
152152latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical
153153points, and the camera supports controlled `region`, one-time `initialRegion`,
154and `fitToBounds`. Set `mapsApiKey` in the Android config to emit the SDK manifest key;
154and `fitToBounds`. Select `mapProvider: 'maplibre'` for the key-free native
155MapLibre renderer. It uses native GL views and needs neither a Google map SDK
156dependency nor a Google map key. `mapProvider: 'google'` retains the existing
157Google Maps host and uses `mapsApiKey` for its SDK manifest key; Google remains
158the default for compatibility with existing projects.
159
155160Coordinates must be JSON numbers (numeric strings are not coerced), are
156161range-checked at the native boundary, and invalid points or pole-crossing
157162regions are ignored. `fitToBounds` waits for the map viewport to
158163be measured before caching a successful camera fit, so an early map callback
159164does not lose the initial route framing.
160without it, the native map remains an explicit opt-in integration that needs
161the app's own Maps credentials. `SectionList` uses the same
165The shared controls include `showsCompass`, `showsScale`, `pitchEnabled`,
166`cameraPitch`, `cameraHeading`, and floating-control `edgePadding`. Markers
167accept `pinColor` and route-direction `bearing`. The builder's
168`nativeMapProviders` export advertises the providers it actually implements,
169so a build orchestrator can refuse an unsupported explicit choice.
170
171`SectionList` uses the same
162172native scroll host for ordinary `.stx` slot children; grouped section data
163173should use `FlatList` until the section-data protocol is available. The
164174generated host routes both list names through `CraftNativeScrollView` for this
packages/android/fixtures/native-navigation/NativeMapTest.ktadded+305-0
Changes to packages/android/fixtures/native-navigation/NativeMapTest.kt
@@ -0,0 +1,305 @@
1package dev.craft.navigationtest
2
3import android.Manifest
4import android.content.Intent
5import android.content.pm.PackageManager
6import android.graphics.Bitmap
7import android.graphics.Color
8import android.os.SystemClock
9import android.view.View
10import android.view.ViewGroup
11import android.widget.TextView
12import androidx.test.ext.junit.runners.AndroidJUnit4
13import androidx.test.platform.app.InstrumentationRegistry
14import java.io.File
15import java.util.concurrent.CountDownLatch
16import java.util.concurrent.TimeUnit
17import java.util.concurrent.atomic.AtomicInteger
18import java.util.concurrent.atomic.AtomicReference
19import org.json.JSONArray
20import org.json.JSONObject
21import org.junit.Assert.*
22import org.junit.Test
23import org.junit.runner.RunWith
24import org.maplibre.android.geometry.LatLng
25import org.maplibre.android.maps.MapLibreMap
26import org.maplibre.android.maps.MapView
27import org.maplibre.android.style.sources.GeoJsonSource
28import org.maplibre.geojson.Feature
29import org.maplibre.geojson.Point
30
31/** The actual generated host and GL renderer, installed on an Android device. */
32@RunWith(AndroidJUnit4::class)
33class NativeMapTest {
34 private val instrumentation = InstrumentationRegistry.getInstrumentation()
35
36 private fun <T> main(block: () -> T): T {
37 val result = AtomicReference<Result<T>>()
38 instrumentation.runOnMainSync { result.set(runCatching(block)) }
39 return result.get().getOrThrow()
40 }
41
42 private fun awaitState(message: String, block: () -> Boolean) {
43 val deadline = SystemClock.uptimeMillis() + 45_000
44 while (SystemClock.uptimeMillis() < deadline) {
45 if (main(block)) return
46 SystemClock.sleep(100)
47 }
48 assertTrue(message, main(block))
49 }
50
51 private fun props() = JSONObject().put("fitToBounds", true).put("showsCompass", true).put("showsScale", true)
52 .put("pitchEnabled", false).put("rotateEnabled", true)
53 .put("edgePadding", JSONObject().put("top", 88).put("bottom", 180).put("left", 32).put("right", 32))
54
55 private fun overlays(color: String = "#24724b") = JSONArray("""[
56 {"type":"MapPolyline","props":{"id":"route","geometry":[47,11,47.01,11.01],"strokeColor":"#24724b","strokeWidth":5}},
57 {"type":"MapMarker","props":{"id":"start","geometry":[47,11],"pinColor":"$color","title":"Trailhead"}},
58 {"type":"MapMarker","props":{"id":"direction","geometry":[47.005,11.005],"bearing":90,"title":"Recorded direction"}}
59 ]""")
60
61 private fun features(map: MapLibreMap): List<Feature> = map.style?.getSourceAs<GeoJsonSource>("craft-map-overlays")
62 ?.querySourceFeatures(null).orEmpty().distinctBy { it.getStringProperty("craftOverlayId") }
63
64 private fun textViews(view: View): List<TextView> = (if (view is TextView) listOf(view) else emptyList()) +
65 (if (view is ViewGroup) (0 until view.childCount).flatMap { textViews(view.getChildAt(it)) } else emptyList())
66
67 private fun evidence(name: String) {
68 val output = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir")
69 ?: throw AssertionError("Gradle native map evidence directory unavailable")
70 val directory = File(output, "native-maps").apply { mkdirs() }
71 val image = instrumentation.uiAutomation.takeScreenshot() ?: throw AssertionError("Native screenshot unavailable")
72 File(directory, "$name.png").outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
73 image.recycle()
74 }
75
76 private fun awaitBasemapPixels(host: CraftNativeMapView, label: String) {
77 val deadline = SystemClock.uptimeMillis() + 45_000
78 do {
79 val bounds = main {
80 val origin = IntArray(2).also { host.getLocationOnScreen(it) }
81 intArrayOf(origin[0], origin[1], host.width, host.height)
82 }
83 val image = instrumentation.uiAutomation.takeScreenshot() ?: throw AssertionError("Native screenshot unavailable")
84 val visible = try {
85 // Sample across the map, away from controls/credits and the thin
86 // route. A rendered overlay over an empty black surface fails.
87 var painted = 0
88 for (row in 1..8) for (column in 1..8) {
89 val pixel = image.getPixel(bounds[0] + bounds[2] * column / 9, bounds[1] + bounds[3] * row / 9)
90 if (Color.red(pixel) + Color.green(pixel) + Color.blue(pixel) > 60) painted++
91 }
92 painted >= 58
93 } finally { image.recycle() }
94 if (visible) return
95 SystemClock.sleep(100)
96 } while (SystemClock.uptimeMillis() < deadline)
97 evidence("$label-basemap-not-visible")
98 fail("$label did not paint native basemap tiles across the viewport")
99 }
100
101 private fun withMap(block: (MainActivity, CraftNativeMapView, MapView, MapLibreMap, AtomicInteger) -> Unit) {
102 val activity = instrumentation.startActivitySync(Intent(instrumentation.targetContext, MainActivity::class.java)
103 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)) as MainActivity
104 lateinit var host: CraftNativeMapView
105 var hostCreated = false
106 lateinit var widget: MapView
107 lateinit var map: MapLibreMap
108 val ready = CountDownLatch(1)
109 val loaded = AtomicInteger()
110 try {
111 main {
112 host = CraftNativeMapView(activity)
113 hostCreated = true
114 widget = host.getChildAt(0) as MapView
115 widget.addOnDidFinishLoadingMapListener { loaded.incrementAndGet() }
116 host.configure(props(), overlays()) {}
117 activity.setContentView(host)
118 widget.getMapAsync { map = it; ready.countDown() }
119 }
120 assertTrue("The real native map did not initialize", ready.await(20, TimeUnit.SECONDS))
121 awaitState("Native map did not render its recorded overlays") {
122 features(map).map { it.getStringProperty("craftOverlayId") }.toSet() == setOf("route", "start", "direction") &&
123 map.queryRenderedFeatures(map.projection.toScreenLocation(LatLng(47.005, 11.005)), "craft-map-directions").isNotEmpty()
124 }
125 block(activity, host, widget, map, loaded)
126 } finally {
127 main { if (hostCreated) { host.destroy(); host.destroy() }; activity.finish() }
128 }
129 }
130
131 @Test
132 fun keyFreeBasemapsKeepTheRecordedRouteAndDirectionSignsVisible() = withMap { _, host, widget, map, loaded ->
133 for (type in listOf("outdoors", "standard", "satellite", "hybrid")) {
134 val before = loaded.get()
135 val rendered = AtomicInteger()
136 val listener = MapView.OnDidFinishRenderingFrameListener { fully, _, _ -> if (fully) rendered.incrementAndGet() }
137 main {
138 widget.addOnDidFinishRenderingFrameListener(listener)
139 val options = props().put("mapType", if (type == "outdoors") "standard" else type)
140 if (type == "outdoors") options.put("tileOverlay", JSONObject()
141 .put("urlTemplate", "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}")
142 .put("attribution", "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"))
143 host.configure(options, overlays()) {}
144 }
145 awaitState("$type did not finish loading its real native basemap") {
146 loaded.get() > before && rendered.get() > 0 && features(map).size == 3 &&
147 map.queryRenderedFeatures(map.projection.toScreenLocation(LatLng(47.005, 11.005)), "craft-map-directions").isNotEmpty()
148 }
149 awaitBasemapPixels(host, type)
150 main { widget.removeOnDidFinishRenderingFrameListener(listener) }
151 main {
152 val start = features(map).first { it.getStringProperty("craftOverlayId") == "start" }.geometry() as Point
153 assertEquals(47.0, start.latitude(), 0.000001)
154 assertEquals(11.0, start.longitude(), 0.000001)
155 assertEquals(90.0, features(map).first { it.getStringProperty("craftOverlayId") == "direction" }.getNumberProperty("bearing").toDouble(), 0.001)
156 assertTrue("Provider attribution must remain visible", textViews(host).any { it.visibility == View.VISIBLE && (it.text.contains("Esri") || it.text.contains("OpenStreetMap")) })
157 val credit = textViews(host).first { it.text.contains("Esri") || it.text.contains("OpenStreetMap") }
158 assertTrue("Floating profiles must not cover provider attribution", credit.bottom <= host.height - 180 * host.resources.displayMetrics.density)
159 for (point in listOf(LatLng(47.0, 11.0), LatLng(47.01, 11.01))) {
160 val screen = map.projection.toScreenLocation(point)
161 val density = host.resources.displayMetrics.density
162 assertTrue("Route must stay below floating top controls", screen.y >= 88 * density - 8)
163 assertTrue("Route must stay above the floating profile", screen.y <= host.height - 180 * density + 8)
164 }
165 }
166 evidence("map-$type")
167 }
168 }
169
170 @Test
171 fun nativeMapMutationsUpdateThePinAndCameraWithoutReloadingTheStyle() = withMap { _, host, _, map, _ ->
172 val style = main { map.style }
173 val pressed = AtomicReference<String?>()
174 main { host.configure(props().put("pitchEnabled", true).put("cameraPitch", 30).put("cameraHeading", 90), overlays("#2878c8")) { pressed.set(it) } }
175 awaitState("The native map did not apply camera and pin mutations") {
176 val pin = features(map).firstOrNull { it.getStringProperty("craftOverlayId") == "start" }
177 pin?.getStringProperty("color") == "rgba(40,120,200,1.0)" &&
178 kotlin.math.abs(map.cameraPosition.bearing - 90) < 1 && kotlin.math.abs(map.cameraPosition.tilt - 30) < 1 &&
179 map.queryRenderedFeatures(map.projection.toScreenLocation(LatLng(47.0, 11.0)), "craft-map-markers").any { it.getStringProperty("color") == "rgba(40,120,200,1.0)" }
180 }
181 awaitBasemapPixels(host, "mutated-map")
182 main {
183 assertSame("Overlay updates must not reload the native basemap", style, map.style)
184 assertTrue(map.uiSettings.isTiltGesturesEnabled)
185 assertTrue(map.uiSettings.isCompassEnabled)
186 assertEquals(3, features(map).size)
187 }
188 // Inspect the actual rendered pin, not just the JSON property sent to GL.
189 // A native source query can observe new feature properties before the
190 // compositor presents that frame. Require the actual requested color,
191 // using a fresh projection for each capture while the camera settles.
192 val deadline = SystemClock.uptimeMillis() + 45_000
193 var location: Pair<Int, Int>? = null
194 var lastPixel = Color.TRANSPARENT
195 while (SystemClock.uptimeMillis() < deadline && location == null) {
196 val candidate = main {
197 val screen = map.projection.toScreenLocation(LatLng(47.0, 11.0))
198 val origin = IntArray(2).also { host.getLocationOnScreen(it) }
199 (screen.x.toInt() + origin[0]) to (screen.y.toInt() + origin[1])
200 }
201 val image = instrumentation.uiAutomation.takeScreenshot() ?: throw AssertionError("Native screenshot unavailable")
202 try {
203 lastPixel = image.getPixel(candidate.first, candidate.second)
204 if (kotlin.math.abs(Color.red(lastPixel) - 40) <= 8 &&
205 kotlin.math.abs(Color.green(lastPixel) - 120) <= 8 &&
206 kotlin.math.abs(Color.blue(lastPixel) - 200) <= 8) location = candidate
207 } finally { image.recycle() }
208 if (location == null) SystemClock.sleep(100)
209 }
210 if (location == null) evidence("map-mutated-pin-not-visible")
211 assertEquals("The compositor must show the new pin red channel", 40.0, Color.red(lastPixel).toDouble(), 8.0)
212 assertEquals("The compositor must show the new pin green channel", 120.0, Color.green(lastPixel).toDouble(), 8.0)
213 assertEquals("The compositor must show the new pin blue channel", 200.0, Color.blue(lastPixel).toDouble(), 8.0)
214 val tap = location ?: throw AssertionError("The updated native pin was never visible")
215 val downAt = SystemClock.uptimeMillis()
216 for (action in listOf(android.view.MotionEvent.ACTION_DOWN, android.view.MotionEvent.ACTION_UP)) {
217 val touch = android.view.MotionEvent.obtain(downAt, SystemClock.uptimeMillis(), action, tap.first.toFloat(), tap.second.toFloat(), 0)
218 try { assertTrue(instrumentation.uiAutomation.injectInputEvent(touch, true)) } finally { touch.recycle() }
219 }
220 awaitState("Tapping the actual native pin must dispatch its stable marker ID") { pressed.get() == "start" }
221 evidence("map-mutated-pin-and-camera")
222 }
223
224 @Test
225 fun controlledRegionEchoPreservesTheCameraAfterANativeDrag() = withMap { _, host, _, map, _ ->
226 val before = AtomicReference<org.maplibre.android.camera.CameraPosition>()
227 val after = AtomicReference<org.maplibre.android.camera.CameraPosition>()
228 val settled = CountDownLatch(1)
229 val origin = main {
230 host.onRegionChangeComplete = { region ->
231 before.set(map.cameraPosition)
232 host.configure(props().put("region", region), overlays()) {}
233 host.postDelayed({ after.set(map.cameraPosition); settled.countDown() }, 500)
234 }
235 IntArray(2).also { host.getLocationOnScreen(it) } to (host.width to host.height)
236 }
237 val downAt = SystemClock.uptimeMillis()
238 for (step in 0..8) {
239 val action = if (step == 0) android.view.MotionEvent.ACTION_DOWN else if (step == 8) android.view.MotionEvent.ACTION_UP else android.view.MotionEvent.ACTION_MOVE
240 val touch = android.view.MotionEvent.obtain(downAt, SystemClock.uptimeMillis(), action,
241 origin.first[0] + origin.second.first * (0.3f + step * 0.025f),
242 origin.first[1] + origin.second.second * (0.3f + step * 0.01f), 0)
243 try { assertTrue(instrumentation.uiAutomation.injectInputEvent(touch, true)) } finally { touch.recycle() }
244 SystemClock.sleep(35)
245 }
246 assertTrue("The native drag did not publish a settled region", settled.await(15, TimeUnit.SECONDS))
247 main {
248 assertEquals("Echoing a native region must preserve zoom", before.get().zoom, after.get().zoom, 0.001)
249 assertEquals("Echoing a native region must preserve latitude", before.get().target!!.latitude, after.get().target!!.latitude, 0.000001)
250 assertEquals("Echoing a native region must preserve longitude", before.get().target!!.longitude, after.get().target!!.longitude, 0.000001)
251 }
252 evidence("map-controlled-region-after-drag")
253 main {
254 host.configure(props().put("region", JSONObject()
255 .put("center", JSONObject().put("latitude", 47.04).put("longitude", 11.04))
256 .put("span", JSONObject().put("latitudeDelta", 0.004).put("longitudeDelta", 0.004))), overlays()) {}
257 }
258 awaitState("A new controlled region must still move the native camera") {
259 val target = map.cameraPosition.target
260 target != null && kotlin.math.abs(target.latitude - before.get().target!!.latitude) > 0.01 &&
261 kotlin.math.abs(target.longitude - before.get().target!!.longitude) > 0.01
262 }
263 }
264
265 @Test
266 fun backgroundAndResumeKeepTheNativeMapAndCamera() = withMap { activity, host, _, map, _ ->
267 awaitBasemapPixels(host, "before-background")
268 val camera = main { map.cameraPosition }
269 assertTrue("The system Home action must succeed", instrumentation.uiAutomation.performGlobalAction(android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_HOME))
270 awaitState("Home must stop the native map activity before testing its restoration") {
271 activity.lifecycle.currentState == androidx.lifecycle.Lifecycle.State.CREATED && !activity.hasWindowFocus()
272 }
273 instrumentation.uiAutomation.waitForIdle(500, 5_000)
274 assertNotEquals("The Home action must actually background the app", activity.packageName, instrumentation.uiAutomation.rootInActiveWindow?.packageName?.toString())
275 // startActivitySync waits for a new Activity creation; this Intent
276 // deliberately resumes the existing instance instead.
277 instrumentation.targetContext.startActivity(Intent(instrumentation.targetContext, MainActivity::class.java)
278 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP))
279 awaitState("Returning must resume the retained native map activity") {
280 activity.lifecycle.currentState == androidx.lifecycle.Lifecycle.State.RESUMED && activity.hasWindowFocus()
281 }
282 awaitState("The resumed native map must render its retained route and arrows") {
283 features(map).size == 3 && map.queryRenderedFeatures(map.projection.toScreenLocation(LatLng(47.005, 11.005)), "craft-map-directions").isNotEmpty()
284 }
285 awaitBasemapPixels(host, "after-background")
286 main {
287 assertEquals(camera.zoom, map.cameraPosition.zoom, 0.001)
288 assertEquals(camera.target!!.latitude, map.cameraPosition.target!!.latitude, 0.000001)
289 assertEquals(camera.target!!.longitude, map.cameraPosition.target!!.longitude, 0.000001)
290 }
291 evidence("map-resumed-after-home")
292 }
293
294 @Test
295 fun showingTheLocationDotDoesNotRequestPermissionDuringRendering() = withMap { activity, host, _, map, _ ->
296 assertEquals(PackageManager.PERMISSION_DENIED, activity.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION))
297 main {
298 host.configure(props().put("showsUserLocation", true), overlays()) {}
299 assertFalse(map.locationComponent.isLocationComponentActivated)
300 }
301 instrumentation.uiAutomation.waitForIdle(500, 5_000)
302 assertEquals("Rendering a map must not open a permission dialog", activity.packageName, instrumentation.uiAutomation.rootInActiveWindow?.packageName?.toString())
303 evidence("map-location-permission-not-requested")
304 }
305}
packages/android/fixtures/native-navigation/NativeNavigationTest.ktmodified+48-0
Changes to packages/android/fixtures/native-navigation/NativeNavigationTest.kt
@@ -725,6 +725,54 @@ class NativeNavigationTest {
725725 }
726726 }
727727
728 @Test
729 fun nativeTestIdsRemainQueryableWithoutReplacingVisibleLabels() {
730 val automation = instrumentation.uiAutomation
731 val previousServiceInfo = automation.serviceInfo
732 automation.serviceInfo = automation.serviceInfo.apply {
733 flags = flags or android.accessibilityservice.AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS
734 }
735 val activity = instrumentation.startActivitySync(
736 Intent(instrumentation.targetContext, MainActivity::class.java)
737 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
738 ) as MainActivity
739 fun matching(root: AccessibilityNodeInfo, id: String): AccessibilityNodeInfo? {
740 if (root.viewIdResourceName == id) return root
741 for (index in 0 until root.childCount) {
742 val child = root.getChild(index) ?: continue
743 matching(child, id)?.let { return it }
744 }
745 return null
746 }
747 fun node(id: String): AccessibilityNodeInfo {
748 repeat(100) {
749 // Native automation consumes node metadata. Android's direct
750 // find-by-view-ID RPC instead resolves an AAPT resource ID,
751 // which authored STX identities deliberately do not allocate.
752 automation.rootInActiveWindow?.let { matching(it, id) }?.let { return it }
753 SystemClock.sleep(100)
754 }
755 throw AssertionError("The native accessibility tree did not expose STX testID $id")
756 }
757 try {
758 awaitView(activity, "home-title")
759 val title = node("home-title")
760 assertEquals("Home", title.text.toString())
761 assertEquals("The test identity must not become a spoken label", null, title.contentDescription)
762 val toggle = node("toggle-safe-area")
763 assertEquals("Toggle safe edges", toggle.text.toString())
764 assertTrue("The identified native button did not accept its accessibility click",
765 toggle.performAction(AccessibilityNodeInfo.ACTION_CLICK))
766 awaitText(activity, "safe-area-status", "Safe edges: bottom")
767 assertEquals("The identity disappeared after native reconciliation", "Toggle safe edges",
768 node("toggle-safe-area").text.toString())
769 } finally {
770 instrumentation.runOnMainSync { activity.finishAndRemoveTask() }
771 automation.serviceInfo = previousServiceInfo
772 instrumentation.waitForIdleSync()
773 }
774 }
775
728776 @Test
729777 fun outgoingTabTapWinsOverSettlingSwipe() {
730778 assertTabTapInterruptsSettlingSwipe(0, "navigation-home-state", "Home state: Ada/1")
packages/android/scripts/compile-templates.tsmodified+8-0
Changes to packages/android/scripts/compile-templates.ts
@@ -27,6 +27,14 @@ const keepProjects = process.argv.includes('--keep')
2727const workspace = createTempDir('craft-android-templates-')
2828
2929const fixtures: CompileFixture[] = [
30 {
31 // Compile the key-free engine in both Debug and minified Release, with
32 // real MapLibre and location APIs resolved from the published SDK.
33 directoryName: 'native-maplibre',
34 name: 'CraftNativeMapLibre',
35 packageName: 'dev.craft.fixture.maplibre',
36 config: { renderer: 'native', mapProvider: 'maplibre', enableGeolocation: true },
37 },
3038 {
3139 directoryName: 'native',
3240 name: 'CraftNativeTemplate',
packages/android/scripts/test-native-navigation.tsmodified+2-0
Changes to packages/android/scripts/test-native-navigation.ts
@@ -31,6 +31,7 @@ try {
3131 name: 'NativeNavigation', packageName, output,
3232 config: {
3333 renderer: 'native',
34 mapProvider: 'maplibre',
3435 nativeScreens: {
3536 '/': 'home',
3637 '/details': 'details',
@@ -63,6 +64,7 @@ try {
6364 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeCapabilitiesTest.kt'), join(testDirectory, 'NativeCapabilitiesTest.kt'))
6465 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeRecordingTest.kt'), join(testDirectory, 'NativeRecordingTest.kt'))
6566 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeCapabilityUiTest.kt'), join(testDirectory, 'NativeCapabilityUiTest.kt'))
67 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeMapTest.kt'), join(testDirectory, 'NativeMapTest.kt'))
6668 const debugDirectory = join(output, 'app/src/debug/java', ...packageName.split('.'))
6769 mkdirSync(debugDirectory, { recursive: true })
6870 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeSystemBarsReferenceActivity.kt'), join(debugDirectory, 'NativeSystemBarsReferenceActivity.kt'))
packages/android/src/index.test.tsmodified+1-0
Changes to packages/android/src/index.test.ts
@@ -794,6 +794,7 @@ describe('Craft Android builder', () => {
794794 const templates = readdirSync(join(import.meta.dir, '../templates'))
795795 .filter(name => name.endsWith('.kt.template'))
796796 const generator = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8')
797 + readFileSync(join(import.meta.dir, 'native-map.ts'), 'utf8')
797798
798799 for (const template of templates) expect(generator).toContain(`'${template}'`)
799800 })
packages/android/src/index.tsmodified+13-4
Changes to packages/android/src/index.ts
@@ -7,8 +7,12 @@
77import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
88import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
99import { $ } from 'bun'
10import { androidMapHost, type AndroidMapProvider } from './native-map.js'
1011import { renderAndroidPromiseRuntime } from './promise-runtime'
1112
13export type { AndroidMapProvider } from './native-map.js'
14/** Providers this published builder can generate as real Android map views. */
15export const nativeMapProviders = ['google', 'maplibre'] as const
1216export { runKotlinFixture } from './testing.js'
1317export { createAndroidEmulator } from './emulator.js'
1418export type { AndroidEmulatorOptions, AndroidEmulatorSession } from './emulator.js'
@@ -155,7 +159,9 @@ export interface CraftAndroidConfig {
155159 trustedOrigins?: string[]
156160 appIconPath?: string
157161 googleServicesFile?: string
158 /** Google Maps Android SDK key used by native `Map` screens. */
162 /** Native map renderer. MapLibre needs no SDK key; Google remains the default for compatibility. */
163 mapProvider?: AndroidMapProvider
164 /** Google Maps Android SDK key, used only by the Google native map host. */
159165 mapsApiKey?: string
160166 devServerURL?: string
161167 hasBundledFallback?: boolean
@@ -448,6 +454,7 @@ function normalizeAndroidNetworkConfig(config: CraftAndroidConfig): void {
448454}
449455
450456function validateAndroidConfig(config: CraftAndroidConfig): void {
457 androidMapHost(config.mapProvider)
451458 if (!config.appName.trim()) throw new Error('Android app name must not be empty')
452459 if (config.renderer !== undefined && config.renderer !== 'web' && config.renderer !== 'native') {
453460 throw new Error(`Unknown Android renderer: ${config.renderer}. Expected web or native.`)
@@ -923,6 +930,8 @@ export async function init(options: InitOptions): Promise<void> {
923930 config.renderer === 'native' ? 'MainActivityNative.kt.template' : 'MainActivity.kt.template',
924931 ), 'utf-8')
925932 const mainActivity = mainActivityTemplate
933 .replace(/\{\{NATIVE_MAP_IMPORTS\}\}/g, () => androidMapHost(config.mapProvider).imports)
934 .replace(/\{\{NATIVE_MAP_HOST\}\}/g, () => readFileSync(join(TEMPLATES_DIR, androidMapHost(config.mapProvider).template), 'utf8'))
926935 .replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName)
927936 .replace(/\{\{APP_NAME\}\}/g, name)
928937
@@ -1049,7 +1058,7 @@ export async function init(options: InitOptions): Promise<void> {
10491058 // Starts the crash reporter before Application.onCreate, for every way
10501059 // the process starts (CraftCrashReporter.kt). Inert without a key.
10511060 .replace(/\{\{CRASH_REPORTER_PROVIDER\}\}/g, ` <provider android:name=".CraftCrashReporterInit" android:authorities="\${applicationId}.craft-crash" android:exported="false" android:initOrder="1000" />`)
1052 .replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapsApiKey
1061 .replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapProvider !== 'maplibre' && config.mapsApiKey
10531062 ? ` <meta-data android:name="com.google.android.geo.API_KEY" android:value="${escapeXml(config.mapsApiKey)}" />`
10541063 : '')
10551064 .replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect
@@ -1088,8 +1097,8 @@ export async function init(options: InitOptions): Promise<void> {
10881097 .replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === 'native'
10891098 ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")'
10901099 : '')
1091 .replace(/\{\{GOOGLE_MAPS_DEPENDENCY\}\}/g, config.renderer === 'native'
1092 ? ' // Native STX Map overlays and trail routes\n implementation("com.google.android.gms:play-services-maps:18.2.0")'
1100 .replace(/\{\{NATIVE_MAP_DEPENDENCY\}\}/g, config.renderer === 'native'
1101 ? ` // Native STX Map overlays and trail routes\n implementation("${androidMapHost(config.mapProvider).dependency}")`
10931102 : '')
10941103 .replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect
10951104 ? ` implementation("androidx.health.connect:connect-client:1.1.0")
packages/android/src/native-map.test.tsadded+60-0
Changes to packages/android/src/native-map.test.ts
@@ -0,0 +1,60 @@
1import { afterAll, describe, expect, it } from 'bun:test'
2import { readFileSync } from 'node:fs'
3import { join } from 'node:path'
4import { init } from './index'
5import { createTempDir, removeAllTempDirs } from '../../typescript/src/temp-dir'
6
7afterAll(removeAllTempDirs)
8
9describe('native Android map provider generation', () => {
10 it('builds a MapLibre host without a Google map SDK dependency or key', async () => {
11 const output = createTempDir('craft-native-maplibre-')
12 await init({ name: 'Native maps', packageName: 'com.craft.native', output, runtimeDir: null, config: { renderer: 'native', mapProvider: 'maplibre', enableGeolocation: true } })
13 const source = readFileSync(join(output, 'app/src/main/java/com/craft/native/MainActivity.kt'), 'utf8')
14 const gradle = readFileSync(join(output, 'app/build.gradle.kts'), 'utf8')
15 const manifest = readFileSync(join(output, 'app/src/main/AndroidManifest.xml'), 'utf8')
16 expect(source).toContain('MapLibre.getInstance(context)')
17 expect(source).toContain('MapView(context)')
18 expect(source).toContain('GeoJsonSource("craft-map-overlays")')
19 expect(source).toContain('iconRotationAlignment("map")')
20 expect(source).toContain('LocationComponentActivationOptions.builder(context, style)')
21 expect(source).not.toContain('com.google.android.gms.maps')
22 expect(source).not.toContain('GoogleMap')
23 expect(source).not.toContain('{{NATIVE_MAP_')
24 expect(gradle).toContain('org.maplibre.gl:android-sdk:13.7.1')
25 expect(gradle).not.toContain('play-services-maps')
26 expect(manifest).not.toContain('com.google.android.geo.API_KEY')
27 expect(JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8')).mapProvider).toBe('maplibre')
28 // Native STX uses the Android JavaScript engine and real views, not the
29 // ts-maps DOM adapter mounted as a substitute for the native map.
30 expect(source).toContain('JavaScriptSandbox.isSupported()')
31 expect(source).not.toContain('import android.webkit.WebView')
32 })
33
34 it('keeps the existing Google host when explicitly selected', async () => {
35 const output = createTempDir('craft-native-google-map-')
36 await init({ name: 'Google maps', packageName: 'com.craft.native', output, runtimeDir: null, config: { renderer: 'native', mapProvider: 'google', mapsApiKey: 'restricted-fixture-key' } })
37 const source = readFileSync(join(output, 'app/src/main/java/com/craft/native/MainActivity.kt'), 'utf8')
38 const gradle = readFileSync(join(output, 'app/build.gradle.kts'), 'utf8')
39 const manifest = readFileSync(join(output, 'app/src/main/AndroidManifest.xml'), 'utf8')
40 expect(source).toContain('com.google.android.gms.maps.GoogleMap')
41 expect(source).not.toContain('org.maplibre.android')
42 expect(gradle).toContain('com.google.android.gms:play-services-maps:18.2.0')
43 expect(gradle).not.toContain('org.maplibre.gl:android-sdk')
44 expect(manifest).toContain('android:name="com.google.android.geo.API_KEY" android:value="restricted-fixture-key"')
45 })
46
47 it('does not emit an unused Google map key for a MapLibre app', async () => {
48 const output = createTempDir('craft-maplibre-manifest-')
49 await init({ name: 'MapLibre', output, runtimeDir: null, config: { renderer: 'native', mapProvider: 'maplibre', mapsApiKey: 'unused-fixture-key' } })
50 const manifest = readFileSync(join(output, 'app/src/main/AndroidManifest.xml'), 'utf8')
51 expect(manifest).not.toContain('com.google.android.geo.API_KEY')
52 expect(manifest).not.toContain('unused-fixture-key')
53 })
54
55 it('rejects an unsupported native map provider before generating a project', async () => {
56 const output = createTempDir('craft-unknown-map-provider-')
57 // Exercise untyped JSON input, which still needs runtime validation.
58 await expect(init({ name: 'Unknown maps', output, runtimeDir: null, config: { renderer: 'native', mapProvider: 'unknown' as never } })).rejects.toThrow('Unknown Android native map provider')
59 })
60})
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/CraftNativeMapGoogle.kt.templateadded+440-0
Changes to packages/android/templates/CraftNativeMapGoogle.kt.template
@@ -0,0 +1,440 @@
1/** The Android host for the small, host-neutral native trail map contract. */
2private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
3 private val mapView = MapView(context)
4 private val scaleView = LinearLayout(context).apply {
5 orientation = LinearLayout.VERTICAL
6 setPadding(4, 2, 4, 2)
7 background = GradientDrawable().apply { setColor(Color.argb(230, 255, 255, 255)); cornerRadius = 4f }
8 importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
9 }
10 private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) }
11 private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) }
12 private var googleMap: GoogleMap? = null
13 private var tileOverlay: com.google.android.gms.maps.model.TileOverlay? = null
14 private var tileTemplate: String? = null
15 private val tileAttribution = TextView(context).apply { textSize = 9f; setTextColor(Color.DKGRAY); setBackgroundColor(Color.argb(220, 255, 255, 255)); maxLines = 2; visibility = View.GONE }
16 private var pendingProps = JSONObject()
17 private var pendingChildren = JSONArray()
18 private val markers = mutableMapOf<String, Marker>()
19 private val polylines = mutableMapOf<String, Polyline>()
20 private val markerHandlers = mutableMapOf<String, String>()
21 private var onMarkerTap: ((String) -> Unit)? = null
22 var onMapPress: ((LatLng) -> Unit)? = null
23 var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
24 private var userChangingCamera = false
25 private var markerBearings = mutableMapOf<String, Float>()
26 private var markerPinColors = mutableMapOf<String, Int>()
27 private val pinIcons = mutableMapOf<Int, com.google.android.gms.maps.model.BitmapDescriptor>()
28 private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null
29 private var lifecycleStarted = false
30 private var destroyed = false
31 private var initialCameraKey: String? = null
32
33 init {
34 addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
35 scaleView.addView(scaleLabel)
36 scaleView.addView(scaleLine, LinearLayout.LayoutParams(1, 2))
37 addView(scaleView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
38 leftMargin = (8 * resources.displayMetrics.density).roundToInt()
39 bottomMargin = (24 * resources.displayMetrics.density).roundToInt()
40 })
41 scaleView.visibility = View.GONE
42 addView(tileAttribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply { rightMargin = (8 * resources.displayMetrics.density).roundToInt(); bottomMargin = (8 * resources.displayMetrics.density).roundToInt() })
43 mapView.onCreate(null)
44 mapView.getMapAsync(this)
45 }
46
47 override fun onMapReady(map: GoogleMap) {
48 if (destroyed) return
49 googleMap = map
50 map.setOnMapClickListener { point -> onMapPress?.invoke(point) }
51 map.setOnCameraMoveStartedListener { reason ->
52 if (reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) userChangingCamera = true
53 }
54 map.setOnCameraMoveListener { updateScale() }
55 map.setOnCameraIdleListener {
56 updateScale()
57 if (userChangingCamera) {
58 userChangingCamera = false
59 val bounds = map.projection.visibleRegion.latLngBounds
60 val position = map.cameraPosition
61 val longitudeDelta = (bounds.northeast.longitude - bounds.southwest.longitude + 360) % 360
62 onRegionChangeComplete?.invoke(JSONObject()
63 .put("center", JSONObject().put("latitude", position.target.latitude).put("longitude", position.target.longitude))
64 .put("span", JSONObject().put("latitudeDelta", bounds.northeast.latitude - bounds.southwest.latitude).put("longitudeDelta", longitudeDelta))
65 .put("heading", position.bearing).put("pitch", position.tilt))
66 }
67 }
68 map.setOnMarkerClickListener { marker ->
69 val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
70 onMarkerTap?.invoke(id)
71 // The native contract dispatches the marker event itself. Consume
72 // the click so Google Maps does not also show an unsolicited info
73 // window for the marker title.
74 true
75 }
76 applyPending()
77 }
78
79 fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
80 pendingProps = props
81 pendingChildren = children
82 onMarkerTap = markerHandler
83 applyPending()
84 }
85
86 fun markerHandler(id: String): String? = markerHandlers[id]
87
88 private fun applyPending() {
89 val map = googleMap ?: return
90 applyMapPadding()
91 map.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
92 // The toolbar is replaced by authored actions; the scale uses the native projection.
93 map.uiSettings.isMapToolbarEnabled = false
94 map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
95 map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
96 map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
97 map.uiSettings.isTiltGesturesEnabled = craftNativeBoolean(pendingProps.opt("pitchEnabled"), true)
98 map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) {
99 "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
100 "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
101 "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
102 else -> GoogleMap.MAP_TYPE_NORMAL
103 }
104 applyTileOverlay()
105 val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission()
106 runCatching { map.isMyLocationEnabled = showUserLocation }
107 val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
108 val nextBearings = mutableMapOf<String, Float>()
109 val nextPinColors = mutableMapOf<String, Int>()
110 val nextHandlers = mutableMapOf<String, String>()
111 val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
112 for (index in 0 until pendingChildren.length()) {
113 val child = pendingChildren.optJSONObject(index) ?: continue
114 val type = child.optString("type").lowercase()
115 val props = child.optJSONObject("props") ?: JSONObject()
116 val events = child.optJSONObject("events") ?: JSONObject()
117 val id = craftNativeOptionalString(props.opt("id"))
118 ?: craftNativeOptionalString(child.opt("id"))
119 ?: "overlay-$index"
120 val points = coordinates(props.optJSONArray("geometry"))
121 if (type == "mapmarker" || type == "marker") {
122 points.firstOrNull()?.let { point ->
123 nextMarkers[id] = point to (props.opt("title") as? String)
124 (props.opt("pinColor") as? String)?.let { value -> runCatching { craftNativeColor(value) }.getOrNull()?.let { nextPinColors[id] = it } }
125 (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
126 listOf(events.opt("onPress"), events.opt("onMarkerPress"))
127 .firstNotNullOfOrNull(::craftNativeOptionalString)
128 ?.let { nextHandlers[id] = it }
129 }
130 } else if (type == "mappolyline" || type == "polyline") {
131 if (points.size > 1) {
132 // Keep malformed authored widths inert, matching the iOS
133 // host. An omitted or null width still gets the shared
134 // default; an attacker-controlled NaN, infinity, zero,
135 // or negative value must not become a valid overlay.
136 val rawWidth = props.opt("strokeWidth")
137 val authoredWidth = when (rawWidth) {
138 null, JSONObject.NULL -> 3.0
139 is Number -> rawWidth.toDouble()
140 else -> 3.0
141 }
142 // MapKit's lineWidth is expressed in points, while the
143 // Google Maps SDK consumes screen pixels. Keep the
144 // protocol in logical points and convert only at this
145 // Android host boundary so dense displays stay in parity.
146 val width = (authoredWidth * resources.displayMetrics.density).toFloat()
147 if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
148 nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
149 }
150 }
151 }
152 markerBearings = nextBearings
153 markerPinColors = nextPinColors
154 pinIcons.keys.filter { it !in markerPinColors.values }.forEach { pinIcons.remove(it) }
155 markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
156 for ((id, value) in nextMarkers) {
157 val marker = markers[id]
158 if (marker != null) {
159 marker.position = value.first
160 marker.title = value.second
161 marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id))
162 marker.isFlat = markerBearings.containsKey(id)
163 marker.rotation = markerBearings[id] ?: 0f
164 marker.setAnchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f)
165 } else {
166 map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id)).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it }
167 }
168 }
169 markerHandlers.clear()
170 markerHandlers.putAll(nextHandlers)
171 polylines.keys.filter { nextPolylines[it] == null }.forEach { id ->
172 polylines.remove(id)?.remove()
173 }
174 for ((id, value) in nextPolylines) {
175 val polyline = polylines[id]
176 if (polyline != null) {
177 polyline.points = value.first
178 polyline.color = value.second
179 polyline.width = value.third
180 } else {
181 map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it }
182 }
183 }
184 val explicitRegion = region(pendingProps.optJSONObject("region"))
185 val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
186 if (explicitRegion != null) {
187 // A controlled region is an exact camera request. The padding
188 // used for fitToBounds is framing chrome, not part of the region
189 // contract, and would otherwise zoom every Android screen out.
190 moveCamera(explicitRegion, padding = 0)
191 initialCameraKey = null
192 } else if (initialRegion != null) {
193 val key = "region:${pendingProps.optJSONObject("initialRegion")}"
194 if (initialCameraKey != key) {
195 moveCamera(initialRegion, padding = 0)
196 initialCameraKey = key
197 }
198 } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
199 val key = "fit:${overlayKey(nextMarkers, nextPolylines)}:${pendingProps.optJSONObject("edgePadding")}"
200 if (initialCameraKey != key) {
201 if (fitToBounds()) initialCameraKey = key
202 }
203 } else {
204 initialCameraKey = null
205 }
206 applyCameraOrientation()
207 updateScale()
208 }
209
210 private fun applyTileOverlay() {
211 val map = googleMap ?: return
212 val options = pendingProps.optJSONObject("tileOverlay")
213 val template = options?.optString("urlTemplate").orEmpty()
214 val credit = options?.optString("attribution").orEmpty()
215 val next = template.takeIf { it.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { key -> it.contains(key) } && credit.isNotBlank() }
216 if (next != tileTemplate) {
217 tileOverlay?.remove(); tileOverlay = null; tileTemplate = next
218 if (next != null) {
219 val provider = object : com.google.android.gms.maps.model.UrlTileProvider(256, 256) {
220 override fun getTileUrl(x: Int, y: Int, zoom: Int): java.net.URL? = runCatching { java.net.URL(next.replace("{x}", x.toString()).replace("{y}", y.toString()).replace("{z}", zoom.toString())) }.getOrNull()
221 }
222 tileOverlay = map.addTileOverlay(com.google.android.gms.maps.model.TileOverlayOptions().tileProvider(provider).zIndex(-1f))
223 }
224 }
225 if (next != null) map.mapType = GoogleMap.MAP_TYPE_NONE
226 tileAttribution.text = credit
227 tileAttribution.visibility = if (next == null) View.GONE else View.VISIBLE
228 }
229
230 private fun applyMapPadding() {
231 val authored = pendingProps.optJSONObject("edgePadding")
232 fun inset(name: String): Double = (authored?.opt(name) as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 10_000.0)?.times(resources.displayMetrics.density) ?: 0.0
233 var left = inset("left"); var top = inset("top"); var right = inset("right"); var bottom = inset("bottom")
234 val horizontal = left + right
235 val vertical = top + bottom
236 val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
237 val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
238 if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
239 if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
240 googleMap?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
241 }
242
243 private fun updateScale() {
244 val map = googleMap ?: return
245 if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scaleView.visibility = View.GONE; return }
246 val maximumWidth = (80 * resources.displayMetrics.density).roundToInt()
247 val origin = map.projection.fromScreenLocation(android.graphics.Point(width / 2, height / 2))
248 val end = map.projection.fromScreenLocation(android.graphics.Point(width / 2 + maximumWidth, height / 2))
249 val distance = FloatArray(1)
250 android.location.Location.distanceBetween(origin.latitude, origin.longitude, end.latitude, end.longitude, distance)
251 val metres = distance[0].toDouble()
252 if (!metres.isFinite() || metres <= 0) { scaleView.visibility = View.GONE; return }
253 val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(metres)))
254 val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= metres }?.times(magnitude) ?: magnitude
255 scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m"
256 scaleLine.layoutParams = LinearLayout.LayoutParams((maximumWidth * length / metres).roundToInt().coerceAtLeast(1), 2)
257 scaleView.visibility = View.VISIBLE
258 }
259
260 private fun applyCameraOrientation() {
261 val map = googleMap ?: return
262 val camera = map.cameraPosition
263 val pitch = (pendingProps.opt("cameraPitch") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 60.0)?.toFloat() ?: camera.tilt
264 val heading = (pendingProps.opt("cameraHeading") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.toFloat() ?: camera.bearing
265 if (kotlin.math.abs(camera.tilt - pitch) > 0.01f || kotlin.math.abs(camera.bearing - heading) > 0.01f) {
266 map.moveCamera(CameraUpdateFactory.newCameraPosition(com.google.android.gms.maps.model.CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build()))
267 }
268 }
269
270 private fun pinMarkerIcon(id: String): com.google.android.gms.maps.model.BitmapDescriptor {
271 val color = markerPinColors[id] ?: return BitmapDescriptorFactory.defaultMarker()
272 return pinIcons.getOrPut(color) {
273 val size = (18 * resources.displayMetrics.density).roundToInt().coerceAtLeast(18)
274 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
275 val canvas = Canvas(bitmap)
276 val paint = Paint(Paint.ANTI_ALIAS_FLAG)
277 paint.color = Color.WHITE
278 canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
279 paint.color = color
280 canvas.drawCircle(size / 2f, size / 2f, size * 0.38f, paint)
281 BitmapDescriptorFactory.fromBitmap(bitmap)
282 }
283 }
284
285 private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor {
286 directionIcon?.let { return it }
287 val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
288 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
289 val canvas = Canvas(bitmap)
290 val paint = Paint(Paint.ANTI_ALIAS_FLAG)
291 paint.color = Color.WHITE
292 canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
293 paint.color = Color.rgb(36, 114, 75)
294 canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint)
295 paint.color = Color.WHITE
296 paint.textSize = size * 0.76f
297 paint.textAlign = Paint.Align.CENTER
298 paint.typeface = Typeface.DEFAULT_BOLD
299 canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint)
300 return BitmapDescriptorFactory.fromBitmap(bitmap).also { directionIcon = it }
301 }
302
303 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
304 super.onSizeChanged(width, height, oldWidth, oldHeight)
305 applyMapPadding()
306 updateScale()
307 // MapView can become ready before its host has a usable viewport. Do
308 // not mark a fit as complete until the camera update can be issued.
309 if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) {
310 applyPending()
311 }
312 }
313
314 private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) {
315 post {
316 if (width > 0 && height > 0) {
317 googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding))
318 applyCameraOrientation()
319 }
320 }
321 }
322
323 private fun overlayKey(
324 markers: Map<String, Pair<LatLng, String?>>,
325 polylines: Map<String, Triple<List<LatLng>, Int, Float>>,
326 ): String {
327 val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) ->
328 "$id:${value.first.latitude},${value.first.longitude}"
329 }
330 val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) ->
331 "$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" }
332 }
333 return "m[$markerKey]p[$polylineKey]"
334 }
335
336 private fun fitToBounds(): Boolean {
337 val map = googleMap ?: return false
338 if (width <= 0 || height <= 0) return false
339 val builder = LatLngBounds.Builder()
340 var count = 0
341 markers.values.forEach { builder.include(it.position); count++ }
342 polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
343 if (count == 0) return false
344 post {
345 if (width > 0 && height > 0) {
346 applyMapPadding()
347 val padding = if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt()
348 map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), padding))
349 applyCameraOrientation()
350 }
351 }
352 return true
353 }
354
355 override fun onAttachedToWindow() {
356 super.onAttachedToWindow()
357 if (destroyed) return
358 mapView.onStart()
359 mapView.onResume()
360 lifecycleStarted = true
361 }
362
363 override fun onDetachedFromWindow() {
364 if (lifecycleStarted) {
365 mapView.onPause()
366 mapView.onStop()
367 lifecycleStarted = false
368 }
369 super.onDetachedFromWindow()
370 }
371
372 fun destroy() {
373 if (destroyed) return
374 destroyed = true
375 if (lifecycleStarted) {
376 mapView.onPause()
377 mapView.onStop()
378 lifecycleStarted = false
379 }
380 onMarkerTap = null
381 onMapPress = null
382 onRegionChangeComplete = null
383 directionIcon = null
384 pinIcons.clear(); markerPinColors.clear()
385 tileOverlay?.remove(); tileOverlay = null
386 markerHandlers.clear()
387 markers.values.forEach { it.remove() }
388 markers.clear()
389 polylines.values.forEach { it.remove() }
390 polylines.clear()
391 mapView.onDestroy()
392 googleMap = null
393 }
394
395 fun lowMemory() {
396 if (!destroyed) mapView.onLowMemory()
397 }
398
399 private fun hasLocationPermission(): Boolean =
400 ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
401 ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
402
403 private fun coordinates(geometry: JSONArray?): List<LatLng> {
404 if (geometry == null) return emptyList()
405 return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
406 val latitude = finiteNumber(geometry.opt(index)) ?: return@mapNotNull null
407 val longitude = finiteNumber(geometry.opt(index + 1)) ?: return@mapNotNull null
408 if (latitude.isFinite() && longitude.isFinite() &&
409 latitude >= -90.0 && latitude <= 90.0 &&
410 longitude >= -180.0 && longitude <= 180.0
411 ) LatLng(latitude, longitude) else null
412 }
413 }
414
415 private fun region(value: JSONObject?): LatLngBounds? {
416 val center = value?.optJSONObject("center") ?: return null
417 val span = value.optJSONObject("span") ?: return null
418 val latitude = finiteNumber(center.opt("latitude")) ?: return null
419 val longitude = finiteNumber(center.opt("longitude")) ?: return null
420 val latitudeDelta = finiteNumber(span.opt("latitudeDelta"))
421 ?: finiteNumber(span.opt("latitude_delta"))
422 ?: return null
423 val longitudeDelta = finiteNumber(span.opt("longitudeDelta"))
424 ?: finiteNumber(span.opt("longitude_delta"))
425 ?: return null
426 if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() ||
427 latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 ||
428 latitudeDelta <= 0.0 || latitudeDelta > 180.0 ||
429 longitudeDelta <= 0.0 || longitudeDelta > 360.0 ||
430 latitude - latitudeDelta / 2.0 < -90.0 || latitude + latitudeDelta / 2.0 > 90.0
431 ) return null
432 return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
433 }
434
435 private fun finiteNumber(value: Any?): Double? =
436 (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
437
438 private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
439}
440
packages/android/templates/CraftNativeMapLibre.kt.templateadded+438-0
Changes to packages/android/templates/CraftNativeMapLibre.kt.template
@@ -0,0 +1,438 @@
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 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}
packages/android/templates/MainActivityNative.kt.templatemodified+6-427
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
@@ -597,422 +587,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
597587 private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4)
598588}
599589
600/** The Android host for the small, host-neutral native trail map contract. */
601private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
602 private val mapView = MapView(context)
603 private val scaleView = LinearLayout(context).apply {
604 orientation = LinearLayout.VERTICAL
605 setPadding(4, 2, 4, 2)
606 background = GradientDrawable().apply { setColor(Color.argb(230, 255, 255, 255)); cornerRadius = 4f }
607 importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
608 }
609 private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) }
610 private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) }
611 private var googleMap: GoogleMap? = null
612 private var tileOverlay: com.google.android.gms.maps.model.TileOverlay? = null
613 private var tileTemplate: String? = null
614 private val tileAttribution = TextView(context).apply { textSize = 9f; setTextColor(Color.DKGRAY); setBackgroundColor(Color.argb(220, 255, 255, 255)); maxLines = 2; visibility = View.GONE }
615 private var pendingProps = JSONObject()
616 private var pendingChildren = JSONArray()
617 private val markers = mutableMapOf<String, Marker>()
618 private val polylines = mutableMapOf<String, Polyline>()
619 private val markerHandlers = mutableMapOf<String, String>()
620 private var onMarkerTap: ((String) -> Unit)? = null
621 var onMapPress: ((LatLng) -> Unit)? = null
622 var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
623 private var userChangingCamera = false
624 private var markerBearings = mutableMapOf<String, Float>()
625 private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null
626 private var lifecycleStarted = false
627 private var destroyed = false
628 private var initialCameraKey: String? = null
629
630 init {
631 addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
632 scaleView.addView(scaleLabel)
633 scaleView.addView(scaleLine, LinearLayout.LayoutParams(1, 2))
634 addView(scaleView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
635 leftMargin = (8 * resources.displayMetrics.density).roundToInt()
636 bottomMargin = (24 * resources.displayMetrics.density).roundToInt()
637 })
638 scaleView.visibility = View.GONE
639 addView(tileAttribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply { rightMargin = (8 * resources.displayMetrics.density).roundToInt(); bottomMargin = (8 * resources.displayMetrics.density).roundToInt() })
640 mapView.onCreate(null)
641 mapView.getMapAsync(this)
642 }
643
644 override fun onMapReady(map: GoogleMap) {
645 if (destroyed) return
646 googleMap = map
647 map.setOnMapClickListener { point -> onMapPress?.invoke(point) }
648 map.setOnCameraMoveStartedListener { reason ->
649 if (reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) userChangingCamera = true
650 }
651 map.setOnCameraMoveListener { updateScale() }
652 map.setOnCameraIdleListener {
653 updateScale()
654 if (userChangingCamera) {
655 userChangingCamera = false
656 val bounds = map.projection.visibleRegion.latLngBounds
657 val position = map.cameraPosition
658 val longitudeDelta = (bounds.northeast.longitude - bounds.southwest.longitude + 360) % 360
659 onRegionChangeComplete?.invoke(JSONObject()
660 .put("center", JSONObject().put("latitude", position.target.latitude).put("longitude", position.target.longitude))
661 .put("span", JSONObject().put("latitudeDelta", bounds.northeast.latitude - bounds.southwest.latitude).put("longitudeDelta", longitudeDelta))
662 .put("heading", position.bearing).put("pitch", position.tilt))
663 }
664 }
665 map.setOnMarkerClickListener { marker ->
666 val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
667 onMarkerTap?.invoke(id)
668 // The native contract dispatches the marker event itself. Consume
669 // the click so Google Maps does not also show an unsolicited info
670 // window for the marker title.
671 true
672 }
673 applyPending()
674 }
675
676 fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
677 pendingProps = props
678 pendingChildren = children
679 onMarkerTap = markerHandler
680 applyPending()
681 }
682
683 fun markerHandler(id: String): String? = markerHandlers[id]
684
685 private fun applyPending() {
686 val map = googleMap ?: return
687 applyMapPadding()
688 map.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
689 // The toolbar is replaced by authored actions; the scale uses the native projection.
690 map.uiSettings.isMapToolbarEnabled = false
691 map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
692 map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
693 map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
694 map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) {
695 "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
696 "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
697 "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
698 else -> GoogleMap.MAP_TYPE_NORMAL
699 }
700 applyTileOverlay()
701 val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission()
702 runCatching { map.isMyLocationEnabled = showUserLocation }
703 val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
704 val nextBearings = mutableMapOf<String, Float>()
705 val nextHandlers = mutableMapOf<String, String>()
706 val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
707 for (index in 0 until pendingChildren.length()) {
708 val child = pendingChildren.optJSONObject(index) ?: continue
709 val type = child.optString("type").lowercase()
710 val props = child.optJSONObject("props") ?: JSONObject()
711 val events = child.optJSONObject("events") ?: JSONObject()
712 val id = craftNativeOptionalString(props.opt("id"))
713 ?: craftNativeOptionalString(child.opt("id"))
714 ?: "overlay-$index"
715 val points = coordinates(props.optJSONArray("geometry"))
716 if (type == "mapmarker" || type == "marker") {
717 points.firstOrNull()?.let { point ->
718 nextMarkers[id] = point to (props.opt("title") as? String)
719 (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
720 listOf(events.opt("onPress"), events.opt("onMarkerPress"))
721 .firstNotNullOfOrNull(::craftNativeOptionalString)
722 ?.let { nextHandlers[id] = it }
723 }
724 } else if (type == "mappolyline" || type == "polyline") {
725 if (points.size > 1) {
726 // Keep malformed authored widths inert, matching the iOS
727 // host. An omitted or null width still gets the shared
728 // default; an attacker-controlled NaN, infinity, zero,
729 // or negative value must not become a valid overlay.
730 val rawWidth = props.opt("strokeWidth")
731 val authoredWidth = when (rawWidth) {
732 null, JSONObject.NULL -> 3.0
733 is Number -> rawWidth.toDouble()
734 else -> 3.0
735 }
736 // MapKit's lineWidth is expressed in points, while the
737 // Google Maps SDK consumes screen pixels. Keep the
738 // protocol in logical points and convert only at this
739 // Android host boundary so dense displays stay in parity.
740 val width = (authoredWidth * resources.displayMetrics.density).toFloat()
741 if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
742 nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
743 }
744 }
745 }
746 markerBearings = nextBearings
747 markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
748 for ((id, value) in nextMarkers) {
749 val marker = markers[id]
750 if (marker != null) {
751 marker.position = value.first
752 marker.title = value.second
753 marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker())
754 marker.isFlat = markerBearings.containsKey(id)
755 marker.rotation = markerBearings[id] ?: 0f
756 marker.setAnchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f)
757 } else {
758 map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker()).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it }
759 }
760 }
761 markerHandlers.clear()
762 markerHandlers.putAll(nextHandlers)
763 polylines.keys.filter { nextPolylines[it] == null }.forEach { id ->
764 polylines.remove(id)?.remove()
765 }
766 for ((id, value) in nextPolylines) {
767 val polyline = polylines[id]
768 if (polyline != null) {
769 polyline.points = value.first
770 polyline.color = value.second
771 polyline.width = value.third
772 } else {
773 map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it }
774 }
775 }
776 val explicitRegion = region(pendingProps.optJSONObject("region"))
777 val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
778 if (explicitRegion != null) {
779 // A controlled region is an exact camera request. The padding
780 // used for fitToBounds is framing chrome, not part of the region
781 // contract, and would otherwise zoom every Android screen out.
782 moveCamera(explicitRegion, padding = 0)
783 initialCameraKey = null
784 } else if (initialRegion != null) {
785 val key = "region:${pendingProps.optJSONObject("initialRegion")}"
786 if (initialCameraKey != key) {
787 moveCamera(initialRegion, padding = 0)
788 initialCameraKey = key
789 }
790 } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
791 val key = "fit:${overlayKey(nextMarkers, nextPolylines)}:${pendingProps.optJSONObject("edgePadding")}"
792 if (initialCameraKey != key) {
793 if (fitToBounds()) initialCameraKey = key
794 }
795 } else {
796 initialCameraKey = null
797 }
798 applyCameraOrientation()
799 updateScale()
800 }
801
802 private fun applyTileOverlay() {
803 val map = googleMap ?: return
804 val options = pendingProps.optJSONObject("tileOverlay")
805 val template = options?.optString("urlTemplate").orEmpty()
806 val credit = options?.optString("attribution").orEmpty()
807 val next = template.takeIf { it.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { key -> it.contains(key) } && credit.isNotBlank() }
808 if (next != tileTemplate) {
809 tileOverlay?.remove(); tileOverlay = null; tileTemplate = next
810 if (next != null) {
811 val provider = object : com.google.android.gms.maps.model.UrlTileProvider(256, 256) {
812 override fun getTileUrl(x: Int, y: Int, zoom: Int): java.net.URL? = runCatching { java.net.URL(next.replace("{x}", x.toString()).replace("{y}", y.toString()).replace("{z}", zoom.toString())) }.getOrNull()
813 }
814 tileOverlay = map.addTileOverlay(com.google.android.gms.maps.model.TileOverlayOptions().tileProvider(provider).zIndex(-1f))
815 }
816 }
817 if (next != null) map.mapType = GoogleMap.MAP_TYPE_NONE
818 tileAttribution.text = credit
819 tileAttribution.visibility = if (next == null) View.GONE else View.VISIBLE
820 }
821
822 private fun applyMapPadding() {
823 val authored = pendingProps.optJSONObject("edgePadding")
824 fun inset(name: String): Double = (authored?.opt(name) as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 10_000.0)?.times(resources.displayMetrics.density) ?: 0.0
825 var left = inset("left"); var top = inset("top"); var right = inset("right"); var bottom = inset("bottom")
826 val horizontal = left + right
827 val vertical = top + bottom
828 val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
829 val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
830 if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
831 if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
832 googleMap?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
833 }
834
835 private fun updateScale() {
836 val map = googleMap ?: return
837 if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scaleView.visibility = View.GONE; return }
838 val maximumWidth = (80 * resources.displayMetrics.density).roundToInt()
839 val origin = map.projection.fromScreenLocation(android.graphics.Point(width / 2, height / 2))
840 val end = map.projection.fromScreenLocation(android.graphics.Point(width / 2 + maximumWidth, height / 2))
841 val distance = FloatArray(1)
842 android.location.Location.distanceBetween(origin.latitude, origin.longitude, end.latitude, end.longitude, distance)
843 val metres = distance[0].toDouble()
844 if (!metres.isFinite() || metres <= 0) { scaleView.visibility = View.GONE; return }
845 val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(metres)))
846 val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= metres }?.times(magnitude) ?: magnitude
847 scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m"
848 scaleLine.layoutParams = LinearLayout.LayoutParams((maximumWidth * length / metres).roundToInt().coerceAtLeast(1), 2)
849 scaleView.visibility = View.VISIBLE
850 }
851
852 private fun applyCameraOrientation() {
853 val map = googleMap ?: return
854 val camera = map.cameraPosition
855 val pitch = (pendingProps.opt("cameraPitch") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 60.0)?.toFloat() ?: camera.tilt
856 val heading = (pendingProps.opt("cameraHeading") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.toFloat() ?: camera.bearing
857 if (kotlin.math.abs(camera.tilt - pitch) > 0.01f || kotlin.math.abs(camera.bearing - heading) > 0.01f) {
858 map.moveCamera(CameraUpdateFactory.newCameraPosition(com.google.android.gms.maps.model.CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build()))
859 }
860 }
861
862 private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor {
863 directionIcon?.let { return it }
864 val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
865 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
866 val canvas = Canvas(bitmap)
867 val paint = Paint(Paint.ANTI_ALIAS_FLAG)
868 paint.color = Color.WHITE
869 canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
870 paint.color = Color.rgb(36, 114, 75)
871 canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint)
872 paint.color = Color.WHITE
873 paint.textSize = size * 0.76f
874 paint.textAlign = Paint.Align.CENTER
875 paint.typeface = Typeface.DEFAULT_BOLD
876 canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint)
877 return BitmapDescriptorFactory.fromBitmap(bitmap).also { directionIcon = it }
878 }
879
880 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
881 super.onSizeChanged(width, height, oldWidth, oldHeight)
882 applyMapPadding()
883 updateScale()
884 // MapView can become ready before its host has a usable viewport. Do
885 // not mark a fit as complete until the camera update can be issued.
886 if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) {
887 applyPending()
888 }
889 }
890
891 private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) {
892 post {
893 if (width > 0 && height > 0) {
894 googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding))
895 applyCameraOrientation()
896 }
897 }
898 }
899
900 private fun overlayKey(
901 markers: Map<String, Pair<LatLng, String?>>,
902 polylines: Map<String, Triple<List<LatLng>, Int, Float>>,
903 ): String {
904 val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) ->
905 "$id:${value.first.latitude},${value.first.longitude}"
906 }
907 val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) ->
908 "$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" }
909 }
910 return "m[$markerKey]p[$polylineKey]"
911 }
912
913 private fun fitToBounds(): Boolean {
914 val map = googleMap ?: return false
915 if (width <= 0 || height <= 0) return false
916 val builder = LatLngBounds.Builder()
917 var count = 0
918 markers.values.forEach { builder.include(it.position); count++ }
919 polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
920 if (count == 0) return false
921 post {
922 if (width > 0 && height > 0) {
923 applyMapPadding()
924 val padding = if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt()
925 map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), padding))
926 applyCameraOrientation()
927 }
928 }
929 return true
930 }
931
932 override fun onAttachedToWindow() {
933 super.onAttachedToWindow()
934 if (destroyed) return
935 mapView.onStart()
936 mapView.onResume()
937 lifecycleStarted = true
938 }
939
940 override fun onDetachedFromWindow() {
941 if (lifecycleStarted) {
942 mapView.onPause()
943 mapView.onStop()
944 lifecycleStarted = false
945 }
946 super.onDetachedFromWindow()
947 }
948
949 fun destroy() {
950 if (destroyed) return
951 destroyed = true
952 if (lifecycleStarted) {
953 mapView.onPause()
954 mapView.onStop()
955 lifecycleStarted = false
956 }
957 onMarkerTap = null
958 onMapPress = null
959 onRegionChangeComplete = null
960 directionIcon = null
961 tileOverlay?.remove(); tileOverlay = null
962 markerHandlers.clear()
963 markers.values.forEach { it.remove() }
964 markers.clear()
965 polylines.values.forEach { it.remove() }
966 polylines.clear()
967 mapView.onDestroy()
968 googleMap = null
969 }
970
971 fun lowMemory() {
972 if (!destroyed) mapView.onLowMemory()
973 }
974
975 private fun hasLocationPermission(): Boolean =
976 ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
977 ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
978
979 private fun coordinates(geometry: JSONArray?): List<LatLng> {
980 if (geometry == null) return emptyList()
981 return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
982 val latitude = finiteNumber(geometry.opt(index)) ?: return@mapNotNull null
983 val longitude = finiteNumber(geometry.opt(index + 1)) ?: return@mapNotNull null
984 if (latitude.isFinite() && longitude.isFinite() &&
985 latitude >= -90.0 && latitude <= 90.0 &&
986 longitude >= -180.0 && longitude <= 180.0
987 ) LatLng(latitude, longitude) else null
988 }
989 }
990
991 private fun region(value: JSONObject?): LatLngBounds? {
992 val center = value?.optJSONObject("center") ?: return null
993 val span = value.optJSONObject("span") ?: return null
994 val latitude = finiteNumber(center.opt("latitude")) ?: return null
995 val longitude = finiteNumber(center.opt("longitude")) ?: return null
996 val latitudeDelta = finiteNumber(span.opt("latitudeDelta"))
997 ?: finiteNumber(span.opt("latitude_delta"))
998 ?: return null
999 val longitudeDelta = finiteNumber(span.opt("longitudeDelta"))
1000 ?: finiteNumber(span.opt("longitude_delta"))
1001 ?: return null
1002 if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() ||
1003 latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 ||
1004 latitudeDelta <= 0.0 || latitudeDelta > 180.0 ||
1005 longitudeDelta <= 0.0 || longitudeDelta > 360.0 ||
1006 latitude - latitudeDelta / 2.0 < -90.0 || latitude + latitudeDelta / 2.0 > 90.0
1007 ) return null
1008 return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
1009 }
1010
1011 private fun finiteNumber(value: Any?): Double? =
1012 (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
1013
1014 private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
1015}
590
1016591
1017592/** A finite celebration owned by a View, never by the JavaScript frame loop. */
1018593internal class CraftNativeCelebration(private val view: View) {
@@ -7000,6 +6575,7 @@ class MainActivity : AppCompatActivity() {
70006575 val label = craftNativeOptionalString(props.opt("accessibilityLabel"))
70016576 ?: craftNativeOptionalString(props.opt("alt"))?.takeIf { type == "Image" || type == "ImageBackground" }
70026577 val value = craftNativeOptionalString(props.opt("accessibilityValue"))
6578 val testId = craftNativeOptionalString(props.opt("testID"))
70036579 view.contentDescription = when {
70046580 label != null && value != null -> "$label, $value"
70056581 label != null -> label
@@ -7024,6 +6600,9 @@ class MainActivity : AppCompatActivity() {
70246600 ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
70256601 override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
70266602 super.onInitializeAccessibilityNodeInfo(host, info)
6603 // Expose STX test identities to Android's native automation
6604 // tree without replacing the label read by TalkBack.
6605 info.viewIdResourceName = testId
70276606 info.className = when (role) {
70286607 "button" -> Button::class.java.name
70296608 "image" -> ImageView::class.java.name
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")
packages/android/templates/build.gradle.kts.project.templatemodified+1-1
Changes to packages/android/templates/build.gradle.kts.project.template
@@ -1,6 +1,6 @@
11// Top-level build file where you can add configuration options common to all sub-projects/modules.
22plugins {
33 id("com.android.application") version "8.9.1" apply false
4 id("org.jetbrains.kotlin.android") version "2.0.21" apply false
4 id("org.jetbrains.kotlin.android") version "2.2.10" apply false
55
66}
packages/ios/fixtures/native-screen/NativeLayoutUnitTests.swiftmodified+31-0
Changes to packages/ios/fixtures/native-screen/NativeLayoutUnitTests.swift
@@ -601,6 +601,37 @@ final class NativeLayoutUnitTests: XCTestCase {
601601 XCTAssertEqual(after.maxX, before.maxX, accuracy: 1, "absolute sibling changed horizontal position")
602602 }
603603
604 func testAbsoluteChildrenHonorSizeBoundsAndKeepTheirInsetAnchors() async throws {
605 let controller = screen(box("root", style: ["height": 240], [
606 ["id": "expand", "type": "Button", "props": ["testID": "expand", "title": "+"],
607 "style": ["position": "absolute", "top": 12, "right": 12, "minWidth": 44, "minHeight": 44]],
608 box("bounded", style: ["position": "absolute", "right": 12, "bottom": 12, "width": 300, "height": 120, "maxWidth": "50%", "maxHeight": 60]),
609 box("wide-ratio", style: ["position": "absolute", "left": 0, "top": 0, "width": 300, "aspectRatio": 2, "maxWidth": 100]),
610 box("tall-ratio", style: ["position": "absolute", "left": 110, "top": 0, "height": 120, "aspectRatio": 2, "maxHeight": 60]),
611 text("wrapped", "An absolutely positioned label still wraps at its authored maximum width", style: ["position": "absolute", "left": 12, "top": 12, "maxWidth": 80, "fontSize": 17]),
612 ]))
613 await settle(controller)
614 let root = try frame("root", in: controller)
615 let expand = try frame("expand", in: controller)
616 XCTAssertGreaterThanOrEqual(expand.width, 44)
617 XCTAssertEqual(expand.height, 44, accuracy: 0.5, "Floating buttons retain their minimum touch target")
618 XCTAssertEqual(expand.maxX, root.maxX - 12, accuracy: 0.5)
619 let bounded = try frame("bounded", in: controller)
620 XCTAssertEqual(bounded.width, root.width / 2, accuracy: 0.5)
621 XCTAssertEqual(bounded.height, 60, accuracy: 0.5)
622 XCTAssertEqual(bounded.maxX, root.maxX - 12, accuracy: 0.5)
623 XCTAssertEqual(bounded.maxY, root.maxY - 12, accuracy: 0.5)
624 let wideRatio = try frame("wide-ratio", in: controller)
625 XCTAssertEqual(wideRatio.width, 100, accuracy: 0.5)
626 XCTAssertEqual(wideRatio.height, 50, accuracy: 0.5, "An automatic height follows the constrained width")
627 let tallRatio = try frame("tall-ratio", in: controller)
628 XCTAssertEqual(tallRatio.width, 120, accuracy: 0.5, "An automatic width follows the constrained height")
629 XCTAssertEqual(tallRatio.height, 60, accuracy: 0.5)
630 let wrapped = try frame("wrapped", in: controller)
631 XCTAssertEqual(wrapped.width, 80, accuracy: 0.5)
632 XCTAssertGreaterThan(wrapped.height, 44, "Width bounds participate in intrinsic text measurement")
633 }
634
604635 func testAStyleUpdateIsAppliedInPlaceLayoutIncluded() throws {
605636 let controller = screen(box("root", style: ["padding": 10], [
606637 box("grows", style: ["height": 24, "marginHorizontal": 48]),
packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+210-3
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -1,11 +1,106 @@
11import Combine
22import UIKit
33import MapKit
4import CoreLocation
45import XCTest
56@testable import NativeRender
67
8private final class UnitLocationManager: CLLocationManager {
9 var state: CLAuthorizationStatus = .notDetermined
10 var whenInUseRequests = 0
11 var alwaysRequests = 0
12 var fixRequests = 0
13 var onAuthorizationRequest: (() -> Void)?
14 override var authorizationStatus: CLAuthorizationStatus { state }
15 override func requestWhenInUseAuthorization() { whenInUseRequests += 1; onAuthorizationRequest?() }
16 override func requestAlwaysAuthorization() { alwaysRequests += 1; onAuthorizationRequest?() }
17 override func requestLocation() { fixRequests += 1 }
18}
19
720@MainActor
821final class NativeRenderUnitTests: XCTestCase {
22 func testOneShotLocationWaitsForPermissionBeforeStartingItsFixAndTimeout() throws {
23 var config = CraftConfig()
24 config.enableGeolocation = true
25 config.enableBackgroundLocation = true
26 let manager = UnitLocationManager()
27 let coordinator = CraftWebView.Coordinator(config: config, locationManager: manager)
28 let previous = CraftSwiftShim.coordinator
29 CraftSwiftShim.coordinator = coordinator
30 defer { CraftSwiftShim.coordinator = previous }
31 let permission = expectation(description: "foreground location permission requested")
32 manager.onAuthorizationRequest = { permission.fulfill() }
33 let completed = expectation(description: "authorized native fix delivered")
34 var answer: Result<Any, CraftNativeActionError>?
35 CraftWebServices.perform(action: "getCurrentPosition", body: ["timeout": 20]) { result in
36 answer = result
37 completed.fulfill()
38 }
39 wait(for: [permission], timeout: 2)
40 XCTAssertEqual(manager.whenInUseRequests, 1)
41 XCTAssertEqual(manager.alwaysRequests, 0, "One foreground fix does not ask for background access")
42 XCTAssertEqual(manager.fixRequests, 0)
43 coordinator.locationManager(manager, didFailWithError: NSError(domain: kCLErrorDomain, code: CLError.Code.denied.rawValue))
44 let deciding = expectation(description: "person is still reading the permission sheet")
45 DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { deciding.fulfill() }
46 wait(for: [deciding], timeout: 2)
47 XCTAssertNil(answer, "The acquisition timeout excludes time spent deciding permission")
48 manager.state = .authorizedWhenInUse
49 coordinator.locationManagerDidChangeAuthorization(manager)
50 XCTAssertEqual(manager.fixRequests, 1)
51 coordinator.locationManagerDidChangeAuthorization(manager)
52 XCTAssertEqual(manager.fixRequests, 1, "Repeated authorization notifications do not restart acquisition")
53 coordinator.locationManager(manager, didUpdateLocations: [CLLocation(latitude: 34, longitude: -118)])
54 wait(for: [completed], timeout: 2)
55 guard case .success(let value)? = answer else { return XCTFail("Expected an authorized native fix") }
56 XCTAssertEqual((value as? [String: Any])?["latitude"] as? Double, 34)
57 }
58
59 func testOneShotLocationRejectsDeniedPermissionWithoutRequestingAFix() {
60 for status in [CLAuthorizationStatus.denied, .restricted] {
61 var config = CraftConfig()
62 config.enableGeolocation = true
63 let manager = UnitLocationManager()
64 manager.state = status
65 let coordinator = CraftWebView.Coordinator(config: config, locationManager: manager)
66 let previous = CraftSwiftShim.coordinator
67 CraftSwiftShim.coordinator = coordinator
68 let completed = expectation(description: "denied native request settled")
69 CraftWebServices.perform(action: "getCurrentPosition", body: [:]) { result in
70 guard case .failure(let error) = result else { completed.fulfill(); return XCTFail("Denied permission returned a fix") }
71 XCTAssertEqual(error.code, "PERMISSION_DENIED")
72 completed.fulfill()
73 }
74 wait(for: [completed], timeout: 2)
75 XCTAssertEqual(manager.fixRequests, 0)
76 XCTAssertEqual(manager.whenInUseRequests, 0)
77 CraftSwiftShim.coordinator = previous
78 }
79 }
80
81 func testOneShotLocationRejectsWhenPermissionPromptIsDeclined() {
82 var config = CraftConfig()
83 config.enableGeolocation = true
84 let manager = UnitLocationManager()
85 let coordinator = CraftWebView.Coordinator(config: config, locationManager: manager)
86 let previous = CraftSwiftShim.coordinator
87 CraftSwiftShim.coordinator = coordinator
88 defer { CraftSwiftShim.coordinator = previous }
89 let permission = expectation(description: "foreground permission requested")
90 manager.onAuthorizationRequest = { permission.fulfill() }
91 let completed = expectation(description: "declined native request settled")
92 CraftWebServices.perform(action: "getCurrentPosition", body: [:]) { result in
93 guard case .failure(let error) = result else { completed.fulfill(); return XCTFail("Declined permission returned a fix") }
94 XCTAssertEqual(error.code, "PERMISSION_DENIED")
95 completed.fulfill()
96 }
97 wait(for: [permission], timeout: 2)
98 manager.state = .denied
99 coordinator.locationManagerDidChangeAuthorization(manager)
100 wait(for: [completed], timeout: 2)
101 XCTAssertEqual(manager.fixRequests, 0)
102 }
103
9104 func testPanProgressDoesNotInvalidateTheWholeChrome() {
10105 let chrome = CraftChrome.shared
11106 let tabs = chrome.tabs
@@ -799,16 +894,88 @@ final class NativeRenderUnitTests: XCTestCase {
799894 XCTAssertEqual(map.camera.pitch, 30, accuracy: 1)
800895 let annotation = try XCTUnwrap(map.annotations.first)
801896 let marker = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation))
802 XCTAssertNotNil(marker.image)
897 let image = try XCTUnwrap(marker.image)
898 XCTAssertEqual(image.renderingMode, .alwaysOriginal, "MapKit must preserve the arrow's contrasting palette")
899 XCTAssertLessThanOrEqual(image.size.width, 24, "Direction signs must leave the route visible in compact maps")
803900 XCTAssertEqual(marker.accessibilityIdentifier, "direction")
804901 XCTAssertEqual(marker.transform.b, 1, accuracy: 0.02)
805902 }
806903
904 func testMapPreservesAuthoredPolylineOrderAcrossUpdatesAndMoves() throws {
905 let controller = CraftNativeScreenController(config: CraftConfig())
906 let window = try hostWindow()
907 window.rootViewController = controller
908 window.makeKeyAndVisible()
909 defer { window.isHidden = true }
910 controller.loadViewIfNeeded()
911 func document(_ casingWidth: Double, reversed: Bool = false) -> [String: Any] {
912 let casing: [String: Any] = ["type": "MapPolyline", "props": ["id": "casing", "geometry": [37.7, -119.5, 37.71, -119.51], "strokeColor": "#365b2d", "strokeWidth": casingWidth]]
913 let route: [String: Any] = ["type": "MapPolyline", "props": ["id": "route", "geometry": [37.7, -119.5, 37.71, -119.51], "strokeColor": "#91e86b", "strokeWidth": 5]]
914 return ["type": "Map", "props": ["key": "ordered-map"],
915 "style": ["width": 320, "height": 320],
916 "children": reversed ? [route, casing] : [casing, route]]
917 }
918 controller.render(document(9))
919 controller.view.layoutIfNeeded()
920 let map = try XCTUnwrap(find(MKMapView.self, key: "ordered-map", below: controller.view))
921 func widths() throws -> [CGFloat] {
922 try map.overlays.compactMap { $0 as? MKPolyline }.map { line in
923 let renderer = try XCTUnwrap(map.delegate?.mapView?(map, rendererFor: line) as? MKPolylineRenderer)
924 return renderer.lineWidth
925 }
926 }
927 XCTAssertEqual(try widths(), [9, 5], "the bright route draws above its wider casing")
928 let foreground = try XCTUnwrap(map.overlays.compactMap { $0 as? MKPolyline }.last)
929
930 // Replacing a lower overlay must not move it above an unchanged line.
931 controller.render(document(11))
932 controller.view.layoutIfNeeded()
933 XCTAssertTrue(map === find(MKMapView.self, key: "ordered-map", below: controller.view))
934 XCTAssertEqual(try widths(), [11, 5])
935 XCTAssertTrue(map.overlays.compactMap { $0 as? MKPolyline }.last === foreground)
936
937 // Child moves are draw-order changes even when geometry is unchanged.
938 controller.render(document(11, reversed: true))
939 controller.view.layoutIfNeeded()
940 XCTAssertEqual(try widths(), [5, 11])
941 controller.render(document(11))
942 controller.view.layoutIfNeeded()
943 XCTAssertEqual(try widths(), [11, 5])
944 }
945
946 func testMapUpdatesNativePinColorAndTiltGestureWithoutReplacingTheMarker() throws {
947 let controller = CraftNativeScreenController(config: CraftConfig())
948 let window = try hostWindow()
949 window.rootViewController = controller
950 window.makeKeyAndVisible()
951 defer { window.isHidden = true }
952 controller.loadViewIfNeeded()
953 func document(_ color: String, _ pitch: Bool) -> [String: Any] {
954 ["type": "Map", "props": ["key": "colored-map", "pitchEnabled": pitch],
955 "style": ["width": 320, "height": 320],
956 "children": [["type": "MapMarker", "props": ["id": "start", "geometry": [37.7, -119.5], "pinColor": color]]]]
957 }
958 controller.render(document("#24724b", false))
959 controller.view.layoutIfNeeded()
960 let map = try XCTUnwrap(find(MKMapView.self, key: "colored-map", below: controller.view))
961 let annotation = try XCTUnwrap(map.annotations.first)
962 let marker = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation))
963 XCTAssertFalse(map.isPitchEnabled)
964 XCTAssertEqual(marker.tintColor, UIColor(red: 36 / 255, green: 114 / 255, blue: 75 / 255, alpha: 1))
965 XCTAssertEqual(marker.image?.renderingMode, .alwaysOriginal)
966 controller.render(document("#2878c8", true))
967 controller.view.layoutIfNeeded()
968 XCTAssertTrue(map.isPitchEnabled)
969 XCTAssertTrue(map.annotations.first === annotation)
970 let updated = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation))
971 XCTAssertEqual(updated.tintColor, UIColor(red: 40 / 255, green: 120 / 255, blue: 200 / 255, alpha: 1))
972 }
973
807974 func testMapUsesNativeTopographicTilesWithAttribution() throws {
808975 let controller = CraftNativeScreenController(config: CraftConfig())
809976 controller.loadViewIfNeeded()
810977 controller.render([
811 "type": "Map", "props": ["key": "topo-map", "tileOverlay": ["urlTemplate": "https://example.org/tiles/{z}/{y}/{x}.png", "attribution": "Test map data"]],
978 "type": "Map", "props": ["key": "topo-map", "edgePadding": ["bottom": 160], "tileOverlay": ["urlTemplate": "https://example.org/tiles/{z}/{y}/{x}.png", "attribution": "Test map data"]],
812979 "style": ["width": 320, "height": 480],
813980 "children": [["type": "MapPolyline", "props": ["geometry": [37.7, -119.5, 37.72, -119.49]]]],
814981 ])
@@ -817,7 +984,10 @@ final class NativeRenderUnitTests: XCTestCase {
817984 XCTAssertTrue(tiles.canReplaceMapContent)
818985 XCTAssertTrue(map.delegate?.mapView?(map, rendererFor: tiles) is MKTileOverlayRenderer)
819986 XCTAssertTrue(map.overlays.last is MKPolyline, "The route must remain above the basemap")
820 XCTAssertTrue(map.subviews.compactMap { $0 as? UILabel }.contains { $0.text == "Test map data" && !$0.isHidden })
987 controller.view.layoutIfNeeded()
988 map.layoutIfNeeded()
989 let credit = try XCTUnwrap(map.subviews.compactMap { $0 as? UILabel }.first { $0.text == "Test map data" && !$0.isHidden })
990 XCTAssertLessThanOrEqual(credit.frame.maxY, map.bounds.height - 160, "The floating profile must not cover map attribution")
821991 }
822992
823993 func testMapReservesRoomForFloatingControls() throws {
@@ -1701,6 +1871,43 @@ final class NativeRenderUnitTests: XCTestCase {
17011871 XCTAssertTrue(disabled.accessibilityTraits.contains(.notEnabled))
17021872 }
17031873
1874 func testHidingAModalDismissesItsKeyboardWithoutRestoringHiddenInputFocus() throws {
1875 let controller = CraftNativeScreenController(config: CraftConfig())
1876 let window = try hostWindow()
1877 window.rootViewController = controller
1878 window.makeKeyAndVisible()
1879 defer { window.isHidden = true }
1880 controller.render(document([
1881 ["type": "TextInput", "props": ["key": "outside"]],
1882 ["id": "review-modal", "type": "Modal", "props": ["key": "modal", "visible": true, "animationType": "none"],
1883 "children": [["type": "TextInput", "props": ["key": "review", "multiline": true], "events": ["onChangeText": "reviewChanged"]]]],
1884 ]))
1885 controller.view.layoutIfNeeded()
1886 let input = try XCTUnwrap(find(UITextView.self, key: "review", below: controller.view))
1887 XCTAssertTrue(input.becomeFirstResponder())
1888 input.text = "A trail review"
1889 controller.textViewDidChange(input)
1890 _ = try controller.applyMutation(batch(1, [
1891 ["op": "updateNode", "id": "review-modal", "patch": ["props": ["visible": false]]],
1892 ]))
1893 XCTAssertFalse(input.isFirstResponder, "Saving or cancelling a modal must dismiss its keyboard")
1894 let settled = expectation(description: "hidden modal input acknowledgement settled")
1895 DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { settled.fulfill() }
1896 wait(for: [settled], timeout: 2)
1897 XCTAssertFalse(input.isFirstResponder, "A delayed input acknowledgement cannot reopen the keyboard")
1898 _ = try controller.applyMutation(batch(2, [
1899 ["op": "updateNode", "id": "review-modal", "patch": ["props": ["visible": true]]],
1900 ]))
1901 XCTAssertFalse(input.isFirstResponder, "Reopening the modal waits for an explicit focus")
1902 XCTAssertTrue(input.becomeFirstResponder())
1903 let outside = try XCTUnwrap(find(UITextField.self, key: "outside", below: controller.view))
1904 XCTAssertTrue(outside.becomeFirstResponder())
1905 _ = try controller.applyMutation(batch(3, [
1906 ["op": "updateNode", "id": "review-modal", "patch": ["props": ["visible": false]]],
1907 ]))
1908 XCTAssertTrue(outside.isFirstResponder, "Hiding a modal must not dismiss another field's keyboard")
1909 }
1910
17041911 func testAShownModalInAScrollViewCoversTheScreenAndTakesItsTouches() throws {
17051912 let controller = CraftNativeScreenController(config: CraftConfig())
17061913 let window = try hostWindow()
packages/ios/src/index.test.tsmodified+1-1
Changes to packages/ios/src/index.test.ts
@@ -2250,7 +2250,7 @@ describe('Craft iOS builder', () => {
22502250 expect(noFixYet).toBeLessThan(body.indexOf('rejectCallback('))
22512251 expect(noFixYet).toBeLessThan(body.indexOf('sendToWeb("craftLocationError"'))
22522252 // Asks again only for the caller that was told to wait.
2253 expect(body).toContain('guard let self, self.singleLocationCallbackId == waiting else { return }')
2253 expect(body).toContain('guard let self, self.singleLocationCallbackId == waiting, self.singleLocationAcquisitionStarted else { return }')
22542254 expect(body).toContain('self.locationManager?.requestLocation()')
22552255 })
22562256
packages/ios/src/testing.test.tsmodified+34-1
Changes to packages/ios/src/testing.test.ts
@@ -97,6 +97,39 @@ describe('package-owned iOS shells', () => {
9797 }
9898 })
9999
100 it('serializes native slider interactions and verifies app handoffs without launching another app', () => {
101 expect(serializeSimulatorSteps([
102 { command: 'sliderid', id: 'elevation', position: 0.5 },
103 { command: 'expectapp', text: 'com.apple.Maps', seconds: 10 },
104 ])).toBe('sliderid elevation 0.5\nexpectapp com.apple.Maps | 10\n')
105 for (const position of [-1, 1.1, NaN, Infinity])
106 expect(() => serializeSimulatorSteps([{ command: 'sliderid', id: 'elevation', position }])).toThrow()
107 expect(() => serializeSimulatorSteps([{ command: 'sliderid', id: 'two sliders', position: 0.5 }])).toThrow()
108 expect(() => serializeSimulatorSteps([{ command: 'expectapp', text: 'Maps\nlaunch' }])).toThrow()
109 const output = folder()
110 initSimulatorDriver({ output, bundleId: 'org.example.app' })
111 const source = readFileSync(join(output, 'UITests/DriverTests.swift'), 'utf8')
112 expect(source).toContain('slider.adjust(toNormalizedSliderPosition: CGFloat(position))')
113 const handoff = source.slice(source.indexOf('case "expectapp":'), source.indexOf('case "field":'))
114 expect(handoff).toContain('target.wait(for: .runningForeground')
115 expect(handoff).not.toContain('target.launch()')
116 })
117
118 it('checks actual native field values after a real edit-menu gesture', () => {
119 expect(serializeSimulatorSteps([
120 { command: 'longpressid', text: 'route-name' },
121 { command: 'tapexact', text: 'Paste' },
122 { command: 'valueid', id: 'route-name', text: 'https://example.org/trail/7' },
123 ])).toBe('longpressid route-name\ntapexact Paste\nvalueid route-name https://example.org/trail/7\n')
124 expect(() => serializeSimulatorSteps([{ command: 'longpressid', text: 'two fields' }])).toThrow()
125 expect(() => serializeSimulatorSteps([{ command: 'valueid', id: 'route-name', text: 'x\nlaunch' }])).toThrow()
126 const output = folder()
127 initSimulatorDriver({ output, bundleId: 'org.example.app' })
128 const source = readFileSync(join(output, 'UITests/DriverTests.swift'), 'utf8')
129 expect(source).toContain('target.press(forDuration: 1.2)')
130 expect(source).toContain('(target.value as? String) == want')
131 })
132
100133 it('bundles an on-device journey with native XCTest evidence and removes it when returning to server mode', () => {
101134 const output = folder()
102135 const options = { output, bundleId: 'org.example.app', script: 'activate\nshot current\ntree current\n' }
@@ -158,7 +191,7 @@ describe('package-owned iOS shells', () => {
158191 initSimulatorDriver({ output, bundleId: 'org.example.app' })
159192 const source = readFileSync(join(output, 'UITests/DriverTests.swift'), 'utf8')
160193 const handled = new Set([...source.matchAll(/case "([a-z]+)"/g)].map(match => match[1]))
161 const commands: SimulatorStep['command'][] = ['launch', 'activate', 'terminate', 'home', 'back', 'return', 'unlabelled', 'background', 'wait', 'delay', 'net', 'swipe', 'drag', 'edge', 'tabbar', 'tapxy', 'touches', 'field', 'typeid', 'labelid', 'expect', 'expectexact', 'expectid', 'tap', 'tapif', 'tapid', 'tapexact', 'type', 'absent', 'absentid', 'selected', 'tab', 'scrollto', 'dismiss', 'log', 'shot', 'tree', 'audit', 'exec', 'openurl', 'universallink', 'ifid', 'notifications', 'systemexpect', 'systemabsent', 'systemtap', 'systemtree']
194 const commands: SimulatorStep['command'][] = ['launch', 'activate', 'terminate', 'home', 'back', 'return', 'unlabelled', 'background', 'wait', 'delay', 'net', 'swipe', 'drag', 'edge', 'tabbar', 'tapxy', 'touches', 'field', 'typeid', 'labelid', 'valueid', 'sliderid', 'expectapp', 'expect', 'expectexact', 'expectid', 'tap', 'tapif', 'tapid', 'longpressid', 'tapexact', 'type', 'absent', 'absentid', 'selected', 'tab', 'scrollto', 'dismiss', 'log', 'shot', 'tree', 'audit', 'exec', 'openurl', 'universallink', 'ifid', 'notifications', 'systemexpect', 'systemabsent', 'systemtap', 'systemtree']
162195 expect(commands.filter(command => !handled.has(command))).toEqual([])
163196 // ifid re-enters the same dispatcher, so a guarded command is any command.
164197 expect(source).toContain('func run(_ line: String)')
packages/ios/src/testing.tsmodified+13-2
Changes to packages/ios/src/testing.ts
@@ -97,9 +97,13 @@ export type SimulatorStep =
9797 /** One native event record, without quiescence between paths. Times are seconds. */
9898 | { command: 'touches', paths: TimedTouchPoint[][] }
9999 | { command: 'field', index: number, text: string }
100 | { command: 'typeid' | 'labelid', id: string, text: string }
100 | { command: 'typeid' | 'labelid' | 'valueid', id: string, text: string }
101 /** Adjust an actual native UISlider by identifier, using its normalized range. */
102 | { command: 'sliderid', id: string, position: number }
103 /** Assert a handoff reached the foreground application without launching it. */
104 | { command: 'expectapp', text: string, seconds?: number }
101105 | { command: 'expect' | 'expectexact' | 'expectid' | 'systemexpect', text: string, seconds?: number }
102 | { command: 'tap' | 'tapif' | 'tapid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log' | 'systemtap' | 'systemabsent', text: string }
106 | { command: 'tap' | 'tapif' | 'tapid' | 'longpressid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log' | 'systemtap' | 'systemabsent', text: string }
103107 | { command: 'shot' | 'tree' | 'audit' | 'exec' | 'systemtree', name: string }
104108 | { command: 'openurl', url: string }
105109 /** Use Apple's universalLinksOnly API, allowing association downloads up to 60 seconds. */
@@ -152,9 +156,16 @@ export function serializeSimulatorSteps(steps: readonly SimulatorStep[]): string
152156 if (!Number.isInteger(number(step.index))) throw new Error('field index must be an integer')
153157 arg = `${step.index} ${step.text}`
154158 }
159 else if ('position' in step) {
160 if (number(step.position) > 1) throw new Error('Slider position must be between 0 and 1')
161 arg = `${token(step.id)} ${step.position}`
162 }
155163 else if ('id' in step) arg = `${token(step.id)} ${step.text}`
156164 else if ('text' in step) {
157165 arg = step.text
166 if (step.command === 'longpressid') token(arg)
167 if (step.command === 'expectapp' && !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(arg))
168 throw new Error('Expected an application bundle identifier')
158169 if ('seconds' in step && step.seconds !== undefined) {
159170 if (arg.includes('|')) throw new Error('A timed expectation cannot contain |')
160171 arg += ` | ${number(step.seconds)}`
packages/ios/templates/CraftApp.swiftmodified+43-6
Changes to packages/ios/templates/CraftApp.swift
@@ -1991,6 +1991,9 @@ struct CraftWebView: UIViewRepresentable {
19911991 private var locationManager: CLLocationManager?
19921992 private var singleLocationCallbackId: String?
19931993 private var singleLocationTimeoutWorkItem: DispatchWorkItem?
1994 private var singleLocationTimeoutMilliseconds: Double = 30_000
1995 private var singleLocationAcquisitionStarted = false
1996 private var singleLocationAuthorizationRequested = false
19941997 private var locationPermissionCallbackId: String?
19951998 private var locationPermissionRequiresAlways = false
19961999 private var isWatchingLocation = false
@@ -2065,7 +2068,7 @@ struct CraftWebView: UIViewRepresentable {
20652068 // SQLite database
20662069 private var db: OpaquePointer?
20672070
2068 init(config: CraftConfig, recordingDirectory: URL? = nil) {
2071 init(config: CraftConfig, locationManager suppliedLocationManager: CLLocationManager? = nil, recordingDirectory: URL? = nil) {
20692072 self.recordingDirectory = recordingDirectory
20702073 self.config = config
20712074 super.init()
@@ -2073,7 +2076,7 @@ struct CraftWebView: UIViewRepresentable {
20732076 speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
20742077 }
20752078 if config.enableGeolocation {
2076 locationManager = CLLocationManager()
2079 locationManager = suppliedLocationManager ?? CLLocationManager()
20772080 locationManager?.desiredAccuracy = kCLLocationAccuracyBest
20782081 locationManager?.activityType = .fitness
20792082 locationManager?.pausesLocationUpdatesAutomatically = false
@@ -6400,7 +6403,7 @@ struct CraftWebView: UIViewRepresentable {
64006403 }
64016404 singleLocationCallbackId = callbackId
64026405 let maximumAge = max(0, (body["maximumAge"] as? NSNumber)?.doubleValue ?? 0)
6403 if maximumAge > 0,
6406 if (manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse), maximumAge > 0,
64046407 let cachedLocation = manager.location,
64056408 max(0, Date().timeIntervalSince(cachedLocation.timestamp) * 1000) <= maximumAge {
64066409 finishSingleLocationRequest()
@@ -6408,7 +6411,35 @@ struct CraftWebView: UIViewRepresentable {
64086411 return
64096412 }
64106413
6411 let timeoutMs = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
6414 singleLocationTimeoutMilliseconds = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
6415 continueSingleLocationRequest(manager)
6416 }
6417
6418 private func continueSingleLocationRequest(_ manager: CLLocationManager) {
6419 guard let callbackId = singleLocationCallbackId else { return }
6420 switch manager.authorizationStatus {
6421 case .notDetermined:
6422 if !singleLocationAuthorizationRequested {
6423 singleLocationAuthorizationRequested = true
6424 manager.requestWhenInUseAuthorization()
6425 }
6426 return
6427 case .denied, .restricted:
6428 finishSingleLocationRequest()
6429 rejectCallback(callbackId, error: "Location permission was denied.", code: "PERMISSION_DENIED")
6430 return
6431 case .authorizedAlways, .authorizedWhenInUse:
6432 break
6433 @unknown default:
6434 finishSingleLocationRequest()
6435 rejectCallback(callbackId, error: "Location authorization is unavailable.", code: "PERMISSION_DENIED")
6436 return
6437 }
6438 guard !singleLocationAcquisitionStarted else { return }
6439 singleLocationAcquisitionStarted = true
6440 // The acquisition timeout starts after the person decides. A
6441 // permission sheet is not a failed or timed-out GPS fix.
6442 let timeoutMs = singleLocationTimeoutMilliseconds
64126443 let timeoutWorkItem = DispatchWorkItem { [weak self] in
64136444 guard let self, self.singleLocationCallbackId == callbackId else { return }
64146445 self.finishSingleLocationRequest()
@@ -6416,7 +6447,6 @@ struct CraftWebView: UIViewRepresentable {
64166447 }
64176448 singleLocationTimeoutWorkItem = timeoutWorkItem
64186449 DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(min(timeoutMs + 100, Double(Int.max)))), execute: timeoutWorkItem)
6419 requestLocationAuthorization()
64206450 manager.requestLocation()
64216451 }
64226452
@@ -6424,6 +6454,8 @@ struct CraftWebView: UIViewRepresentable {
64246454 singleLocationTimeoutWorkItem?.cancel()
64256455 singleLocationTimeoutWorkItem = nil
64266456 singleLocationCallbackId = nil
6457 singleLocationAcquisitionStarted = false
6458 singleLocationAuthorizationRequested = false
64276459 locationManager?.desiredAccuracy = kCLLocationAccuracyBest
64286460 }
64296461
@@ -6753,6 +6785,10 @@ struct CraftWebView: UIViewRepresentable {
67536785
67546786 func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
67556787 let nativeError = error as NSError
6788 if nativeError.domain == kCLErrorDomain, nativeError.code == CLError.Code.denied.rawValue,
6789 manager.authorizationStatus == .notDetermined, singleLocationCallbackId != nil, !singleLocationAcquisitionStarted {
6790 return
6791 }
67566792 // kCLErrorLocationUnknown is Core Location saying it has no fix
67576793 // *yet*, not that it failed. requestLocation gives up on it, so a
67586794 // one-shot asks again shortly instead of failing a caller whose
@@ -6761,7 +6797,7 @@ struct CraftWebView: UIViewRepresentable {
67616797 if nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.locationUnknown.rawValue {
67626798 if let waiting = singleLocationCallbackId {
67636799 DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
6764 guard let self, self.singleLocationCallbackId == waiting else { return }
6800 guard let self, self.singleLocationCallbackId == waiting, self.singleLocationAcquisitionStarted else { return }
67656801 self.locationManager?.requestLocation()
67666802 }
67676803 }
@@ -6777,6 +6813,7 @@ struct CraftWebView: UIViewRepresentable {
67776813 }
67786814
67796815 func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
6816 if singleLocationCallbackId != nil { continueSingleLocationRequest(manager) }
67806817 guard let callbackId = locationPermissionCallbackId else { return }
67816818 let status = manager.authorizationStatus
67826819 if status == .notDetermined { return }
packages/ios/templates/CraftNativeScreen.swiftmodified+68-10
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -1171,8 +1171,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
11711171 private var authoredHeading: Double?
11721172 private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
11731173 private var markerBearings: [String: Double] = [:]
1174 private var markerPinColors: [String: UIColor] = [:]
11741175 private var tileOverlay: MKTileOverlay?
11751176 private var tileTemplate: String?
1177 private var tileAttributionBottom: NSLayoutConstraint?
11761178 private lazy var tileAttribution: UILabel = {
11771179 let label = UILabel()
11781180 label.font = .systemFont(ofSize: 9)
@@ -1181,7 +1183,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
11811183 label.translatesAutoresizingMaskIntoConstraints = false
11821184 label.numberOfLines = 2
11831185 addSubview(label)
1184 NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
1186 let bottom = label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8)
1187 tileAttributionBottom = bottom
1188 NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), bottom, label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
11851189 return label
11861190 }()
11871191
@@ -1206,6 +1210,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
12061210 isScrollEnabled = props["scrollEnabled"] as? Bool ?? true
12071211 isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
12081212 isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
1213 isPitchEnabled = props["pitchEnabled"] as? Bool ?? true
12091214 mapType = Self.mapType(props["mapType"] as? String)
12101215 configureTiles(props["tileOverlay"] as? [String: Any])
12111216 showsCompass = props["showsCompass"] as? Bool ?? false
@@ -1219,6 +1224,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
12191224 }
12201225 edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
12211226 var nextBearings: [String: Double] = [:]
1227 var nextPinColors: [String: UIColor] = [:]
12221228 // Showing the user dot must not trigger a permission prompt from a
12231229 // renderer pass. The app's existing location capability owns that
12241230 // prompt; a map only opts in after authorization is already granted.
@@ -1227,6 +1233,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
12271233 var nextMarkers: [String: (coordinate: CLLocationCoordinate2D, title: String?)] = [:]
12281234 var nextHandlers: [String: String] = [:]
12291235 var nextPolylines: [String: (coordinates: [CLLocationCoordinate2D], color: UIColor, width: CGFloat)] = [:]
1236 var nextPolylineIDs: [String] = []
12301237 for (index, child) in children.enumerated() {
12311238 guard let node = child as? [String: Any], let rawType = node["type"] as? String else { continue }
12321239 let type = rawType.lowercased()
@@ -1239,6 +1246,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
12391246 if type == "mapmarker" || type == "marker" {
12401247 guard let point = points.first else { continue }
12411248 nextMarkers[id] = (point, childProps["title"] as? String)
1249 if let color = childProps["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
12421250 if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
12431251 nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
12441252 }
@@ -1251,11 +1259,13 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
12511259 let authoredWidth = Self.numeric(childProps["strokeWidth"]) ?? 3
12521260 let width = CGFloat(authoredWidth)
12531261 guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
1262 if nextPolylines[id] == nil { nextPolylineIDs.append(id) }
12541263 nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
12551264 }
12561265 }
12571266
12581267 markerBearings = nextBearings
1268 markerPinColors = nextPinColors
12591269 let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
12601270 removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
12611271 for (id, value) in nextMarkers {
@@ -1282,7 +1292,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
12821292 }
12831293 overlayCoordinatesByID.removeValue(forKey: id)
12841294 }
1285 for (id, value) in nextPolylines {
1295 for id in nextPolylineIDs {
1296 guard let value = nextPolylines[id] else { continue }
12861297 if let overlay = overlaysByID[id],
12871298 let coordinates = overlayCoordinatesByID[id],
12881299 Self.coordinatesEqual(coordinates, value.coordinates),
@@ -1302,6 +1313,19 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
13021313 overlayWidths[ObjectIdentifier(overlay)] = value.width
13031314 addOverlay(overlay)
13041315 }
1316 // Keep authored casing/foreground order when an earlier line changes
1317 // or retained children move. Exchanging native overlays preserves the
1318 // unchanged objects, tile levels and current camera.
1319 let ownedOverlayIDs = Set(overlaysByID.values.map { ObjectIdentifier($0) })
1320 var orderedOverlays = overlays.compactMap { $0 as? MKPolyline }
1321 .filter { ownedOverlayIDs.contains(ObjectIdentifier($0)) }
1322 for (index, id) in nextPolylineIDs.enumerated() {
1323 guard let overlay = overlaysByID[id], index < orderedOverlays.count,
1324 orderedOverlays[index] !== overlay,
1325 let previousIndex = orderedOverlays.firstIndex(where: { $0 === overlay }) else { continue }
1326 exchangeOverlay(orderedOverlays[index], with: orderedOverlays[previousIndex])
1327 orderedOverlays.swapAt(index, previousIndex)
1328 }
13051329
13061330 if let region = Self.region(props["region"]) {
13071331 // `region` is controlled: an authored change is allowed to move
@@ -1411,20 +1435,27 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
14111435
14121436 private func configureMarkerView(_ view: MKAnnotationView, id: String) {
14131437 if let bearing = markerBearings[id] {
1414 let configuration = UIImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
1438 let configuration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
14151439 .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
1416 view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)
1440 // MapKit otherwise tints this as a monochrome template and loses
1441 // the contrasting route arrow when displayed over map content.
1442 view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)?
1443 .withRenderingMode(.alwaysOriginal)
14171444 view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
14181445 } else {
1446 view.tintColor = markerPinColors[id] ?? .systemRed
14191447 view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
1420 .withTintColor(.systemRed, renderingMode: .alwaysOriginal)
1448 .withTintColor(view.tintColor, renderingMode: .alwaysOriginal)
14211449 view.transform = .identity
14221450 }
14231451 }
14241452
14251453 override func layoutSubviews() {
14261454 super.layoutSubviews()
1427 if tileTemplate != nil { bringSubviewToFront(tileAttribution) }
1455 if tileTemplate != nil {
1456 tileAttributionBottom?.constant = -min(edgePadding.bottom, max(0, bounds.height - 40)) - 8
1457 bringSubviewToFront(tileAttribution)
1458 }
14281459 if let region = pendingRegion, bounds.width > 0, bounds.height > 0 {
14291460 pendingRegion = nil
14301461 setRegion(region, animated: false)
@@ -2681,11 +2712,24 @@ private class CraftNativeFlowView: UIStackView {
26812712 var fixedHeight = style.top != nil && style.bottom != nil
26822713 ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
26832714 : style.height?.resolve(content.height)
2715 let authoredWidth = fixedWidth != nil
2716 let authoredHeight = fixedHeight != nil
26842717 (fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
2685 let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
2686 let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
2687 let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
2688 frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height)))
2718 var measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
2719 var boundedWidth = clamp(measured.width, style.minWidth?.resolve(content.width), style.maxWidth?.resolve(content.width))
2720 let proposedHeight = !authoredHeight && style.aspectRatio != nil ? boundedWidth / style.aspectRatio! : fixedHeight
2721 if abs(boundedWidth - measured.width) > 0.0001 || proposedHeight != fixedHeight {
2722 // A narrower label or container must wrap before its
2723 // height and bottom anchor are resolved.
2724 measured = CraftNativeFlexLayout.measure(child, width: boundedWidth, height: proposedHeight, maxWidth: content.width, maxHeight: content.height)
2725 }
2726 let boundedHeight = clamp(measured.height, style.minHeight?.resolve(content.height), style.maxHeight?.resolve(content.height))
2727 if !authoredWidth, authoredHeight, let ratio = style.aspectRatio {
2728 boundedWidth = clamp(boundedHeight * ratio, style.minWidth?.resolve(content.width), style.maxWidth?.resolve(content.width))
2729 }
2730 let x = style.left ?? (style.right.map { content.width - $0 - boundedWidth } ?? 0)
2731 let y = style.top ?? (style.bottom.map { content.height - $0 - boundedHeight } ?? 0)
2732 frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: boundedWidth, height: boundedHeight)))
26892733 }
26902734 }
26912735 return (size, frames)
@@ -3370,6 +3414,8 @@ private final class CraftNativeModalView: UIView {
33703414 let generation = animationGeneration
33713415 layer.removeAllAnimations()
33723416 guard visible else {
3417 craftScreenController?.forgetFocusedInput(in: self)
3418 endEditing(true)
33733419 if animationType == "slide" && wasVisible {
33743420 let distance = max(bounds.height, superview?.bounds.height ?? UIScreen.main.bounds.height)
33753421 UIView.animate(withDuration: 0.24, animations: {
@@ -3409,6 +3455,8 @@ private final class CraftNativeModalView: UIView {
34093455 }
34103456
34113457 func resetForTeardown() {
3458 craftScreenController?.forgetFocusedInput(in: self)
3459 endEditing(true)
34123460 animationGeneration += 1
34133461 layer.removeAllAnimations()
34143462 isHidden = true
@@ -4326,6 +4374,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
43264374 func forgetFocusedInput(_ input: UIView? = nil) {
43274375 if input == nil || lastFocusedInput === input { lastFocusedInput = nil }
43284376 }
4377 func forgetFocusedInput(in container: UIView) {
4378 if let input = lastFocusedInput, input === container || input.isDescendant(of: container) {
4379 lastFocusedInput = nil
4380 }
4381 }
43294382 private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
43304383 private var flatListOwners: [String: String] = [:]
43314384 /// A drawing's shapes are not views: a change to one redraws its `Svg`.
@@ -6054,6 +6107,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
60546107 private func restoreFocus(_ focused: UIView?) {
60556108 guard let focused, focused.window != nil, !focused.isFirstResponder,
60566109 lastFocusedInput === focused, firstResponder(in: rootStack) == nil else { return }
6110 var ancestor: UIView? = focused
6111 while let view = ancestor {
6112 if view.isHidden { return }
6113 ancestor = view.superview
6114 }
60576115 // A delayed acknowledgement belongs to the input that scheduled it.
60586116 // A newer field or an explicit dismissal owns focus now.
60596117 _ = focused.becomeFirstResponder()
packages/ios/templates/testing/DriverTests.swift.templatemodified+37-0
Changes to packages/ios/templates/testing/DriverTests.swift.template
@@ -21,6 +21,7 @@ import Darwin
2121/// tap <text> first hittable element whose label contains text
2222/// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
2323/// tapid <identifier>
24/// longpressid <identifier> holds a hittable native element to open its edit menu
2425/// touches <JSON> timed normalized touch paths in one native dispatch
2526/// tapxy <x> <y> normalised 0…1 of the screen
2627/// tapexact <label> a button whose label is exactly this
@@ -29,11 +30,14 @@ import Darwin
2930/// return the keyboard's return key (Go on a sign-in form)
3031/// field <n> <text> tap the n-th text field (0-based, secure fields after plain) and type
3132/// typeid <identifier> <text> tap the element with this identifier and type
33/// sliderid <identifier> <0…1> adjusts a hittable native slider
34/// expectapp <bundle-id> [| secs] asserts a native app handoff reached the foreground
3235/// expect <text> [| secs] waits for an element containing text; logs PASS/FAIL
3336/// expectexact <label> [| secs] a button or text whose label is exactly this
3437/// absent <text> logs PASS when no element contains text
3538/// expectid <id> [| secs] / absentid <id> the same by accessibility identifier (testID)
3639/// labelid <id> <text> that element's label is exactly text (a count, a status)
40/// valueid <id> <text> that element's actual string value is exactly text
3741/// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
3842/// selected <tab> the tab bar's selected button is this one
3943/// tab <name> tap a tab bar button
@@ -297,6 +301,14 @@ final class DriverTests: XCTestCase {
297301 let target = identified(arg)
298302 if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
299303 else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
304 case "longpressid":
305 let target = identified(arg)
306 guard target.waitForExistence(timeout: 10), target.isHittable else {
307 check(false, "longpressid \(arg) is hittable")
308 return
309 }
310 target.press(forDuration: 1.2)
311 note("longpressid \(arg): ok")
300312 case "touches":
301313 do {
302314 guard let data = arg.data(using: .utf8),
@@ -367,6 +379,23 @@ final class DriverTests: XCTestCase {
367379 app.typeText(idParts.count > 1 ? idParts[1] : "")
368380 note("typeid \(idParts[0]): ok")
369381 } else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
382 case "sliderid":
383 let parts = arg.split(separator: " ", maxSplits: 1).map(String.init)
384 guard parts.count == 2, let position = Double(parts[1]), position.isFinite, (0...1).contains(position) else {
385 check(false, "sliderid requires an identifier and normalized position")
386 return
387 }
388 let slider = app.sliders.matching(NSPredicate(format: "identifier == %@", parts[0])).firstMatch
389 guard slider.waitForExistence(timeout: 10), slider.isHittable else {
390 check(false, "sliderid \(parts[0]) is hittable")
391 return
392 }
393 slider.adjust(toNormalizedSliderPosition: CGFloat(position))
394 note("sliderid \(parts[0]): \(position)")
395 case "expectapp":
396 let (bundle, seconds) = split(arg)
397 let target = XCUIApplication(bundleIdentifier: bundle)
398 check(target.wait(for: .runningForeground, timeout: seconds), "expectapp \(bundle)")
370399 case "field":
371400 let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
372401 let index = Int(fieldParts[0]) ?? 0
@@ -392,6 +421,14 @@ final class DriverTests: XCTestCase {
392421 let want = idParts.count > 1 ? idParts[1] : ""
393422 let seen = target.waitForExistence(timeout: 5) ? target.label : "(missing)"
394423 check(seen == want, "labelid \(idParts[0]) \(want) (saw \(seen))")
424 case "valueid":
425 let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
426 let target = identified(idParts[0])
427 let want = idParts.count > 1 ? idParts[1] : ""
428 let matched = poll(5) {
429 target.exists && (target.value as? String) == want ? target : nil
430 }
431 check(matched != nil, "valueid \(idParts[0]) \(want)")
395432 case "absentid":
396433 check(!identified(arg).exists, "absentid \(arg)")
397434 case "absent":
packages/ts-maps/README.mdmodified+8-1
Changes to packages/ts-maps/README.md
@@ -2,7 +2,14 @@
22
33Craft SDK bindings for the [ts-maps](https://github.com/stacksjs/ts-maps) interactive map runtime.
44
5This package is the TypeScript side of `MapProvider.ts_maps` in Craft's Zig core. It ships a `MapView` component you can drop into any Craft app (desktop, mobile, WebView) to get a cross-platform interactive map with zero API keys.
5This package is the TypeScript side of `MapProvider.ts_maps` in Craft's Zig core.
6Its `MapView` owns a DOM container and renders ts-maps inside a browser or
7WebView, with zero API keys. It is not the STX native `Map` component.
8
9For a fully native STX screen, use `Map`, `MapMarker`, and `MapPolyline`.
10Craft renders them with MapKit on iOS and the configured native Android map
11provider. Android's `mapProvider: 'maplibre'` selects a key-free native GL view;
12it does not mount this package's DOM adapter.
613
714## Install
815
packages/ts-maps/src/MapView.tsmodified+2-2
Changes to packages/ts-maps/src/MapView.ts
@@ -1,6 +1,6 @@
11/**
2 * `MapView` — Craft-native interactive map component backed by the
3 * ts-maps runtime. Instances are WebView-hosted; in native Craft apps
2 * `MapView` — Craft WebView map component backed by the
3 * ts-maps runtime. Instances own a DOM container; in Craft apps
44 * the Zig core drives them via the `ts_maps` bridge namespace.
55 *
66 * @module @craft-native/ts-maps/MapView
scripts/native-renderer-contract.test.tsmodified+5-2
Changes to scripts/native-renderer-contract.test.ts
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
22import { join } from 'node:path'
33import { runInNewContext } from 'node:vm'
44import { describe, expect, it } from 'bun:test'
5import { androidMapHost } from '../packages/android/src/native-map'
56
67const root = join(import.meta.dir, '..')
78const ios = readFileSync(join(root, 'packages/ios/templates/CraftNativeScreen.swift'), 'utf8')
@@ -11,6 +12,8 @@ const iosHealth = readFileSync(join(root, 'packages/ios/templates/CraftHealth.sw
1112const iosWebAudio = readFileSync(join(root, 'packages/ios/templates/CraftWebAudio.swift'), 'utf8')
1213const iosFlatList = readFileSync(join(root, 'packages/ios/templates/CraftNativeFlatList.swift'), 'utf8')
1314const android = readFileSync(join(root, 'packages/android/templates/MainActivityNative.kt.template'), 'utf8')
15 .replace('', () => readFileSync(join(root, 'packages/android/templates', androidMapHost('google').template), 'utf8'))
16 .replace('', () => androidMapHost('google').imports)
1417const androidMutation = readFileSync(join(root, 'packages/android/templates/CraftNativeMutation.kt.template'), 'utf8')
1518const androidBuild = readFileSync(join(root, 'packages/android/templates/build.gradle.kts.app.template'), 'utf8')
1619const androidBuilder = readFileSync(join(root, 'packages/android/src/index.ts'), 'utf8')
renamed throughout · 11 hidden lines
Changes to scripts/native-renderer-contract.test.ts
@@ -568,9 +571,9 @@ describe('native renderer component contract', () => { renamed throughout
568571 expect(android).toContain('override fun onLowMemory()')
569572 expect(android).toContain('mapViews().forEach { it.lowMemory() }')
570573 expect(android).toContain('released.filterIsInstance<CraftNativeMapView>().forEach { it.destroy() }')
571 expect(androidBuild).toContain('')
574 expect(androidBuild).toContain('')
572575 expect(androidBuilder).toContain('mapsApiKey?: string')
573 expect(androidBuilder).toContain(".replace(/\\{\\{GOOGLE_MAPS_DEPENDENCY\\}\\}/g, config.renderer === 'native'")
576 expect(androidBuilder).toContain(".replace(/\\{\\{NATIVE_MAP_DEPENDENCY\\}\\}/g, config.renderer === 'native'")
574577 expect(androidBuilder).toContain('com.google.android.geo.API_KEY')
575578 expect(androidManifest).toContain('')
576579 expect(guide).toContain('| `Map` | `CraftNativeMapView` (`MKMapView`) |')