ReviewOS

also looking at this

craft-native/craft

feat(maps): add key-free native Android maps and shared map controls

#363
Merged chris wants to merge codex/wildloop-native-map-providers into main
28 files +1857 -481

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/workflows/mobile-e2e.ymlmodified+2-2
Changes to .github/workflows/mobile-e2e.yml
@@ -74,7 +74,7 @@ on:
7474 android_suite:
7575 description: Android native fixture coverage for the android-navigation scope
7676 type: choice
77 options: [focused, full, system-bars]
77 options: [focused, full, system-bars, maps]
7878 default: focused
7979 selectors:
8080 description: Optional XCTest selector for focused native iOS rendering or navigation
@@ -607,7 +607,7 @@ jobs:
607607 api-level: ${{ fromJSON((inputs.scope != 'android-navigation' || inputs.android_suite == 'full') && '[34,35]' || inputs.android_suite == 'system-bars' && '[35]' || '[34]') }}
608608 env:
609609 CRAFT_ANDROID_NATIVE_SUITE: ${{ inputs.scope != 'android-navigation' && 'full' || inputs.android_suite || 'focused' }}
610 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' || '' }}
610 CRAFT_ANDROID_TEST_CLASS: ${{ inputs.scope == 'android-navigation' && inputs.android_suite == 'maps' && 'dev.craft.navigationtest.NativeMapTest#keyFreeBasemapsKeepTheRecordedRouteAndDirectionSignsVisible,dev.craft.navigationtest.NativeMapTest#nativeMapMutationsUpdateThePinAndCameraWithoutReloadingTheStyle,dev.craft.navigationtest.NativeMapTest#showingTheLocationDotDoesNotRequestPermissionDuringRendering' || 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' || '' }}
611611 if: ${{ inputs.scope != 'ios-navigation' && inputs.scope != 'ios-render' }}
612612 runs-on: ubuntu-latest
613613 timeout-minutes: 35
packages/android/fixtures/native-navigation/NativeMapTest.ktadded+181-0
Changes to packages/android/fixtures/native-navigation/NativeMapTest.kt
@@ -0,0 +1,181 @@
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 androidx.test.uiautomator.UiDevice
15import java.io.File
16import java.util.concurrent.CountDownLatch
17import java.util.concurrent.TimeUnit
18import java.util.concurrent.atomic.AtomicInteger
19import java.util.concurrent.atomic.AtomicReference
20import org.json.JSONArray
21import org.json.JSONObject
22import org.junit.Assert.*
23import org.junit.Test
24import org.junit.runner.RunWith
25import org.maplibre.android.geometry.LatLng
26import org.maplibre.android.maps.MapLibreMap
27import org.maplibre.android.maps.MapView
28import org.maplibre.android.style.sources.GeoJsonSource
29import org.maplibre.geojson.Feature
30import org.maplibre.geojson.Point
31
32/** The actual generated host and GL renderer, installed on an Android device. */
33@RunWith(AndroidJUnit4::class)
34class NativeMapTest {
35 private val instrumentation = InstrumentationRegistry.getInstrumentation()
36
37 private fun <T> main(block: () -> T): T {
38 val result = AtomicReference<Result<T>>()
39 instrumentation.runOnMainSync { result.set(runCatching(block)) }
40 return result.get().getOrThrow()
41 }
42
43 private fun awaitState(message: String, block: () -> Boolean) {
44 val deadline = SystemClock.uptimeMillis() + 45_000
45 while (SystemClock.uptimeMillis() < deadline) {
46 if (main(block)) return
47 SystemClock.sleep(100)
48 }
49 assertTrue(message, main(block))
50 }
51
52 private fun props() = JSONObject().put("fitToBounds", true).put("showsCompass", true).put("showsScale", true)
53 .put("pitchEnabled", false).put("rotateEnabled", true)
54 .put("edgePadding", JSONObject().put("top", 88).put("bottom", 180).put("left", 32).put("right", 32))
55
56 private fun overlays(color: String = "#24724b") = JSONArray("""[
57 {"type":"MapPolyline","props":{"id":"route","geometry":[47,11,47.01,11.01],"strokeColor":"#24724b","strokeWidth":5}},
58 {"type":"MapMarker","props":{"id":"start","geometry":[47,11],"pinColor":"$color","title":"Trailhead"}},
59 {"type":"MapMarker","props":{"id":"direction","geometry":[47.005,11.005],"bearing":90,"title":"Recorded direction"}}
60 ]""")
61
62 private fun features(map: MapLibreMap): List<Feature> = map.style?.getSourceAs<GeoJsonSource>("craft-map-overlays")
63 ?.querySourceFeatures(null).orEmpty().distinctBy { it.getStringProperty("craftOverlayId") }
64
65 private fun textViews(view: View): List<TextView> = (if (view is TextView) listOf(view) else emptyList()) +
66 (if (view is ViewGroup) (0 until view.childCount).flatMap { textViews(view.getChildAt(it)) } else emptyList())
67
68 private fun evidence(name: String) {
69 val output = InstrumentationRegistry.getArguments().getString("additionalTestOutputDir")
70 ?: throw AssertionError("Gradle native map evidence directory unavailable")
71 val directory = File(output, "native-maps").apply { mkdirs() }
72 val image = instrumentation.uiAutomation.takeScreenshot() ?: throw AssertionError("Native screenshot unavailable")
73 File(directory, "$name.png").outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
74 image.recycle()
75 }
76
77 private fun withMap(block: (MainActivity, CraftNativeMapView, MapView, MapLibreMap, AtomicInteger) -> Unit) {
78 val activity = instrumentation.startActivitySync(Intent(instrumentation.targetContext, MainActivity::class.java)
79 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)) as MainActivity
80 lateinit var host: CraftNativeMapView
81 var hostCreated = false
82 lateinit var widget: MapView
83 lateinit var map: MapLibreMap
84 val ready = CountDownLatch(1)
85 val loaded = AtomicInteger()
86 try {
87 main {
88 host = CraftNativeMapView(activity)
89 hostCreated = true
90 widget = host.getChildAt(0) as MapView
91 widget.addOnDidFinishLoadingMapListener { loaded.incrementAndGet() }
92 host.configure(props(), overlays()) {}
93 activity.setContentView(host)
94 widget.getMapAsync { map = it; ready.countDown() }
95 }
96 assertTrue("The real native map did not initialize", ready.await(20, TimeUnit.SECONDS))
97 awaitState("Native map did not render its recorded overlays") {
98 features(map).map { it.getStringProperty("craftOverlayId") }.toSet() == setOf("route", "start", "direction") &&
99 map.queryRenderedFeatures(map.projection.toScreenLocation(LatLng(47.005, 11.005)), "craft-map-directions").isNotEmpty()
100 }
101 block(activity, host, widget, map, loaded)
102 } finally {
103 main { if (hostCreated) { host.destroy(); host.destroy() }; activity.finish() }
104 }
105 }
106
107 @Test
108 fun keyFreeBasemapsKeepTheRecordedRouteAndDirectionSignsVisible() = withMap { _, host, _, map, loaded ->
109 for (type in listOf("outdoors", "standard", "satellite", "hybrid")) {
110 val before = loaded.get()
111 main {
112 val options = props().put("mapType", if (type == "outdoors") "standard" else type)
113 if (type == "outdoors") options.put("tileOverlay", JSONObject()
114 .put("urlTemplate", "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}")
115 .put("attribution", "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"))
116 host.configure(options, overlays()) {}
117 }
118 awaitState("$type did not finish loading its real native basemap") {
119 loaded.get() > before && features(map).size == 3 &&
120 map.queryRenderedFeatures(map.projection.toScreenLocation(LatLng(47.005, 11.005)), "craft-map-directions").isNotEmpty()
121 }
122 main {
123 val start = features(map).first { it.getStringProperty("craftOverlayId") == "start" }.geometry() as Point
124 assertEquals(47.0, start.latitude(), 0.000001)
125 assertEquals(11.0, start.longitude(), 0.000001)
126 assertEquals(90.0, features(map).first { it.getStringProperty("craftOverlayId") == "direction" }.getNumberProperty("bearing").toDouble(), 0.001)
127 assertTrue("Provider attribution must remain visible", textViews(host).any { it.visibility == View.VISIBLE && (it.text.contains("Esri") || it.text.contains("OpenStreetMap")) })
128 for (point in listOf(LatLng(47.0, 11.0), LatLng(47.01, 11.01))) {
129 val screen = map.projection.toScreenLocation(point)
130 val density = host.resources.displayMetrics.density
131 assertTrue("Route must stay below floating top controls", screen.y >= 88 * density - 8)
132 assertTrue("Route must stay above the floating profile", screen.y <= host.height - 180 * density + 8)
133 }
134 }
135 evidence("map-$type")
136 }
137 }
138
139 @Test
140 fun nativeMapMutationsUpdateThePinAndCameraWithoutReloadingTheStyle() = withMap { _, host, _, map, _ ->
141 val style = main { map.style }
142 main { host.configure(props().put("pitchEnabled", true).put("cameraPitch", 30).put("cameraHeading", 90), overlays("#2878c8")) {} }
143 awaitState("The native map did not apply camera and pin mutations") {
144 val pin = features(map).firstOrNull { it.getStringProperty("craftOverlayId") == "start" }
145 pin?.getStringProperty("color") == "rgba(40,120,200,1.0)" &&
146 kotlin.math.abs(map.cameraPosition.bearing - 90) < 1 && kotlin.math.abs(map.cameraPosition.tilt - 30) < 1 &&
147 map.queryRenderedFeatures(map.projection.toScreenLocation(LatLng(47.0, 11.0)), "craft-map-markers").any { it.getStringProperty("color") == "rgba(40,120,200,1.0)" }
148 }
149 main {
150 assertSame("Overlay updates must not reload the native basemap", style, map.style)
151 assertTrue(map.uiSettings.isTiltGesturesEnabled)
152 assertTrue(map.uiSettings.isCompassEnabled)
153 assertEquals(3, features(map).size)
154 }
155 // Inspect the actual rendered pin, not just the JSON property sent to GL.
156 val location = main {
157 val screen = map.projection.toScreenLocation(LatLng(47.0, 11.0))
158 val origin = IntArray(2).also { host.getLocationOnScreen(it) }
159 (screen.x.toInt() + origin[0]) to (screen.y.toInt() + origin[1])
160 }
161 val image = instrumentation.uiAutomation.takeScreenshot() ?: throw AssertionError("Native screenshot unavailable")
162 try {
163 val pixel = image.getPixel(location.first, location.second)
164 assertEquals(40.0, Color.red(pixel).toDouble(), 8.0)
165 assertEquals(120.0, Color.green(pixel).toDouble(), 8.0)
166 assertEquals(200.0, Color.blue(pixel).toDouble(), 8.0)
167 } finally { image.recycle() }
168 evidence("map-mutated-pin-and-camera")
169 }
170
171 @Test
172 fun showingTheLocationDotDoesNotRequestPermissionDuringRendering() = withMap { activity, host, _, map, _ ->
173 assertEquals(PackageManager.PERMISSION_DENIED, activity.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION))
174 main {
175 host.configure(props().put("showsUserLocation", true), overlays()) {}
176 assertFalse(map.locationComponent.isLocationComponentActivated)
177 }
178 assertEquals("Rendering a map must not open a permission dialog", activity.packageName, UiDevice.getInstance(instrumentation).currentPackageName)
179 evidence("map-location-permission-not-requested")
180 }
181}
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',
@@ -62,6 +63,7 @@ try {
6263 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeFlatListTest.kt'), join(testDirectory, 'NativeFlatListTest.kt'))
6364 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeCapabilitiesTest.kt'), join(testDirectory, 'NativeCapabilitiesTest.kt'))
6465 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeCapabilityUiTest.kt'), join(testDirectory, 'NativeCapabilityUiTest.kt'))
66 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeMapTest.kt'), join(testDirectory, 'NativeMapTest.kt'))
6567 const debugDirectory = join(output, 'app/src/debug/java', ...packageName.split('.'))
6668 mkdirSync(debugDirectory, { recursive: true })
6769 copyFileSync(join(import.meta.dir, '../fixtures/native-navigation/NativeSystemBarsReferenceActivity.kt'), join(debugDirectory, 'NativeSystemBarsReferenceActivity.kt'))
packages/android/templates/CraftNativeMapLibre.kt.templatemodified+15-3
Changes to packages/android/templates/CraftNativeMapLibre.kt.template
@@ -47,6 +47,7 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
4747 bottomMargin = (8 * resources.displayMetrics.density).roundToInt()
4848 })
4949 mapView.onCreate(null)
50 mapView.addOnDidFinishLoadingMapListener { if (!destroyed && initialCameraKey == null) applyPending() }
5051 mapView.getMapAsync(this)
5152 }
5253
@@ -128,7 +129,7 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
128129 val key = "region:${pendingProps.optJSONObject("initialRegion")}"
129130 if (initialCameraKey != key) { moveCamera(initial, 0); initialCameraKey = key }
130131 } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
131 val key = "$width,$height:${pendingProps.optJSONObject("edgePadding")}:" + geometryPoints.joinToString(";") { "${it.latitude},${it.longitude}" }
132 val key = "$width,$height:${pendingProps.optJSONObject("edgePadding")}:${pendingProps.opt("cameraPitch")}:${pendingProps.opt("cameraHeading")}:" + geometryPoints.joinToString(";") { "${it.latitude},${it.longitude}" }
132133 if (initialCameraKey != key && geometryPoints.isNotEmpty() && width > 0 && height > 0) {
133134 val distinct = geometryPoints.distinctBy { it.latitude to it.longitude }
134135 if (distinct.size == 1) nativeMap.moveCamera(CameraUpdateFactory.newLatLngZoom(distinct.first(), 15.0))
@@ -198,7 +199,7 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
198199 val events = child.optJSONObject("events") ?: JSONObject()
199200 val id = craftNativeOptionalString(props.opt("id")) ?: craftNativeOptionalString(child.opt("id")) ?: "overlay-$index"
200201 val points = coordinates(props.optJSONArray("geometry"))
201 val properties = JSONObject()
202 val properties = JSONObject().put("craftOverlayId", id)
202203 val geometry = JSONObject()
203204 if ((type == "mapmarker" || type == "marker") && points.isNotEmpty()) {
204205 val point = points.first()
@@ -273,7 +274,18 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
273274 }
274275
275276 private fun moveCamera(bounds: LatLngBounds, padding: Int) {
276 post { if (!destroyed && width > 0 && height > 0) { map?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)); applyOrientation() } }
277 post {
278 val nativeMap = map
279 if (!destroyed && nativeMap != null && width > 0 && height > 0) {
280 val camera = nativeMap.cameraPosition
281 val bearing = finiteNumber(pendingProps.opt("cameraHeading")) ?: camera.bearing
282 val tilt = finiteNumber(pendingProps.opt("cameraPitch"))?.coerceIn(0.0, 60.0) ?: camera.tilt
283 val insets = nativeMap.padding.map { it + padding }.toIntArray()
284 val target = nativeMap.getCameraForLatLngBounds(bounds, insets, bearing, tilt)
285 if (target == null) initialCameraKey = null
286 else nativeMap.moveCamera(CameraUpdateFactory.newCameraPosition(target))
287 }
288 }
277289 }
278290
279291 private fun applyOrientation() {