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
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}