also looking at this
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
| @@ -0,0 +1,305 @@ | ||
| 1 | +package dev.craft.navigationtest | |
| 2 | + | |
| 3 | +import android.Manifest | |
| 4 | +import android.content.Intent | |
| 5 | +import android.content.pm.PackageManager | |
| 6 | +import android.graphics.Bitmap | |
| 7 | +import android.graphics.Color | |
| 8 | +import android.os.SystemClock | |
| 9 | +import android.view.View | |
| 10 | +import android.view.ViewGroup | |
| 11 | +import android.widget.TextView | |
| 12 | +import androidx.test.ext.junit.runners.AndroidJUnit4 | |
| 13 | +import androidx.test.platform.app.InstrumentationRegistry | |
| 14 | +import java.io.File | |
| 15 | +import java.util.concurrent.CountDownLatch | |
| 16 | +import java.util.concurrent.TimeUnit | |
| 17 | +import java.util.concurrent.atomic.AtomicInteger | |
| 18 | +import java.util.concurrent.atomic.AtomicReference | |
| 19 | +import org.json.JSONArray | |
| 20 | +import org.json.JSONObject | |
| 21 | +import org.junit.Assert.* | |
| 22 | +import org.junit.Test | |
| 23 | +import org.junit.runner.RunWith | |
| 24 | +import org.maplibre.android.geometry.LatLng | |
| 25 | +import org.maplibre.android.maps.MapLibreMap | |
| 26 | +import org.maplibre.android.maps.MapView | |
| 27 | +import org.maplibre.android.style.sources.GeoJsonSource | |
| 28 | +import org.maplibre.geojson.Feature | |
| 29 | +import org.maplibre.geojson.Point | |
| 30 | + | |
| 31 | +/** The actual generated host and GL renderer, installed on an Android device. */ | |
| 32 | +@RunWith(AndroidJUnit4::class) | |
| 33 | +class 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 | +} | |