diff --git a/api/android-for-googlemaps.api b/api/android-for-googlemaps.api index 40a1ff6..7e5265a 100644 --- a/api/android-for-googlemaps.api +++ b/api/android-for-googlemaps.api @@ -140,7 +140,7 @@ public final class com.mapconductor.googlemaps.GoogleMapViewHolder implements co public java.lang.Object getMap(); } public final class com.mapconductor.googlemaps.GoogleMapViewKt { - public static final void GoogleMapView(com.mapconductor.googlemaps.GoogleMapViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void GoogleMapView(com.mapconductor.googlemaps.GoogleMapViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); public static final com.mapconductor.googlemaps.GoogleMapViewController createGoogleMapViewController(com.mapconductor.googlemaps.GoogleMapViewHolder, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.MutableMapServiceRegistry); } public final class com.mapconductor.googlemaps.GoogleMapViewSaver extends com.mapconductor.compose.map.BaseMapViewSaver { @@ -308,6 +308,8 @@ public final class com.mapconductor.googlemaps.polyline.GoogleMapPolylineOverlay public final class com.mapconductor.googlemaps.raster.GoogleMapRasterLayerController extends com.mapconductor.core.raster.RasterLayerController implements com.mapconductor.googlemaps.raster.GoogleMapRasterLayerControllerInterface { public com.mapconductor.googlemaps.raster.GoogleMapRasterLayerController(com.mapconductor.core.raster.RasterLayerManagerInterface, com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer); public com.mapconductor.googlemaps.raster.GoogleMapRasterLayerController(com.mapconductor.core.raster.RasterLayerManagerInterface, com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer, int, kotlin.jvm.internal.DefaultConstructorMarker); + public java.lang.Object onCameraChanged(com.mapconductor.core.map.MapCameraPosition, kotlin.coroutines.Continuation); + public void destroy(); } public interface com.mapconductor.googlemaps.raster.GoogleMapRasterLayerControllerInterface extends com.mapconductor.core.controller.OverlayControllerInterface> { } diff --git a/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt b/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt index 64869fb..d24b239 100644 --- a/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt +++ b/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt @@ -20,8 +20,7 @@ import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface import com.mapconductor.core.map.MapCapability import com.mapconductor.core.map.MapCapabilityStatus -import com.mapconductor.core.raster.RasterTilePreference -import com.mapconductor.core.raster.RasterTilePreferenceKey +import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.map.MutableMapServiceRegistry import com.mapconductor.core.marker.MarkerEventControllerInterface import com.mapconductor.core.marker.MarkerOverlayRendererInterface @@ -30,6 +29,8 @@ import com.mapconductor.core.marker.MarkerRenderingSupport import com.mapconductor.core.marker.MarkerRenderingSupportKey import com.mapconductor.core.marker.MarkerTilingOptions import com.mapconductor.core.marker.StrategyMarkerController +import com.mapconductor.core.raster.RasterTilePreference +import com.mapconductor.core.raster.RasterTilePreferenceKey import com.mapconductor.googlemaps.circle.GoogleMapCircleController import com.mapconductor.googlemaps.circle.GoogleMapCircleOverlayRenderer import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageController @@ -60,6 +61,15 @@ fun GoogleMapView( onCameraMoveStart: OnCameraMoveHandler? = null, onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable GoogleMapViewScope.() -> Unit)? = null, ) { val scope = remember { GoogleMapViewScope() } // Use specific scope @@ -171,6 +181,8 @@ fun GoogleMapView( // Pass content if it needs to be rendered within the overlay providers in MapViewBase, // or handle it here if it's specific to GoogleMapView structure before calling MapViewBase. // For now, assuming content relates to overlay definitions. + style = style, + onStyleDiagnostics = onStyleDiagnostics, content = content, // This might need adjustment based on how overlays are handled ) } diff --git a/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerCamera.kt b/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerCamera.kt index b98bc8c..89be439 100644 --- a/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerCamera.kt @@ -74,6 +74,7 @@ internal fun GoogleMapViewController.handleCameraMove() { } internal fun GoogleMapViewController.handleCameraIdle() { + rasterLayerController.onCameraIdle() val mapCameraPosition = getMapCameraPosition() defaultCoroutine.launch { markerController.onCameraChanged(mapCameraPosition) } emitCameraMoveEnd(getMapCameraPosition()) diff --git a/src/main/java/com/mapconductor/googlemaps/raster/GoogleLocalTileRequestQueue.kt b/src/main/java/com/mapconductor/googlemaps/raster/GoogleLocalTileRequestQueue.kt new file mode 100644 index 0000000..f7dccf2 --- /dev/null +++ b/src/main/java/com/mapconductor/googlemaps/raster/GoogleLocalTileRequestQueue.kt @@ -0,0 +1,99 @@ +package com.mapconductor.googlemaps.raster + +import com.mapconductor.core.InternalMapConductorApi +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.math.abs + +/** Bounds local renders and discards obsolete zooms before they occupy a worker. */ +@InternalMapConductorApi +internal class GoogleLocalTileRequestQueue( + private val width: Int = 4, + private val render: (String, () -> Boolean) -> ByteArray?, +) { + @InternalMapConductorApi + private class Job(val zoom: Int) { + val cancelled = AtomicBoolean(false) + } + + private val lock = ReentrantLock() + private val changed = lock.newCondition() + private val pending = mutableListOf() + private val active = mutableSetOf() + private val retryAfterZoom = AtomicBoolean(false) + private var zoom: Double? = null + private var closed = false + + init { + require(width > 0) + } + + fun updateZoom(next: Double) { + if (!next.isFinite()) return + lock.withLock { + zoom = next + for (job in pending + active) { + if (abs(job.zoom - next) > 1.5 && job.cancelled.compareAndSet(false, true)) retryAfterZoom.set(true) + } + pending.removeAll { it.cancelled.get() } + changed.signalAll() + } + } + + fun consumeRetryAfterZoom(): Boolean = retryAfterZoom.getAndSet(false) + + fun close() { + lock.withLock { + closed = true + for (job in pending + active) job.cancelled.set(true) + pending.clear() + changed.signalAll() + } + } + + fun renderTile(url: String, tileZoom: Int): ByteArray? { + val job = Job(tileZoom) + lock.withLock { + if (closed || zoom?.let { abs(tileZoom - it) > 1.5 } == true) return null + pending.add(job) + try { + while (!job.cancelled.get()) { + val current = zoom + val next = pending.minByOrNull { abs(it.zoom - (current ?: it.zoom.toDouble())) } + if (active.size < width && next === job) { + pending.remove(job) + active.add(job) + break + } + if (current != null && next != null) { + val closest = abs(next.zoom - current) + for (running in active) { + if (abs(running.zoom - current) > closest + 0.25 && running.cancelled.compareAndSet(false, true)) { + retryAfterZoom.set(true) + } + } + } + changed.await() + } + if (job.cancelled.get()) return null + } catch (_: InterruptedException) { + job.cancelled.set(true) + Thread.currentThread().interrupt() + return null + } finally { + pending.remove(job) + changed.signalAll() + } + } + return try { + val bytes = render(url) { job.cancelled.get() } + if (job.cancelled.get()) null else bytes + } finally { + lock.withLock { + active.remove(job) + changed.signalAll() + } + } + } +} diff --git a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerController.kt b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerController.kt index 34cdf79..c004bd7 100644 --- a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerController.kt +++ b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerController.kt @@ -2,6 +2,8 @@ package com.mapconductor.googlemaps.raster import com.google.android.gms.maps.model.TileOverlay import com.mapconductor.core.controller.OverlayControllerInterface +import com.mapconductor.core.map.MapCameraPosition +import kotlinx.coroutines.withContext import com.mapconductor.core.raster.RasterLayerController import com.mapconductor.core.raster.RasterLayerEntityInterface import com.mapconductor.core.raster.RasterLayerManager @@ -18,4 +20,19 @@ internal class GoogleMapRasterLayerController( rasterLayerManager: RasterLayerManagerInterface = RasterLayerManager(), renderer: GoogleMapRasterLayerOverlayRenderer, ) : RasterLayerController(rasterLayerManager, renderer), - GoogleMapRasterLayerControllerInterface + GoogleMapRasterLayerControllerInterface { + private val tileRenderer = renderer + + override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { + withContext(tileRenderer.coroutine.coroutineContext) { + tileRenderer.updateLocalZoom(mapCameraPosition.zoom) + } + } + + internal fun onCameraIdle() { tileRenderer.refreshLocalTilesAfterZoom() } + + override fun destroy() { + tileRenderer.cancelLocalRequests() + super.destroy() + } +} diff --git a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt index 43239bd..870bdcf 100644 --- a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt @@ -10,6 +10,7 @@ import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface import com.mapconductor.core.raster.RasterLayerSource import com.mapconductor.core.raster.RasterLayerState import com.mapconductor.core.raster.TileScheme +import com.mapconductor.core.tileserver.TileServerRegistry import com.mapconductor.googlemaps.GoogleMapViewHolder import okhttp3.Headers import okhttp3.OkHttpClient @@ -32,6 +33,25 @@ class GoogleMapRasterLayerOverlayRenderer( private val okHttpClient: OkHttpClient, override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), ) : RasterLayerOverlayRendererInterface { + private val localRequests = mutableMapOf() + + internal fun updateLocalZoom(zoom: Double) { + localRequests.values.forEach { it.updateZoom(zoom) } + } + + internal fun refreshLocalTilesAfterZoom() { + // Google backs off retryable null tiles. Reset that backoff after a + // zoom settles, so returning to a cancelled zoom does not wait seconds. + localRequests.forEach { (overlay, requests) -> + if (requests.consumeRetryAfterZoom()) overlay.clearTileCache() + } + } + + internal fun cancelLocalRequests() { + localRequests.values.forEach { it.close() } + localRequests.clear() + } + private fun isMarkerTileRaster(state: RasterLayerState): Boolean = state.id.startsWith(MARKER_TILE_RASTER_ID_PREFIX) private fun resolveOverlayZIndex(state: RasterLayerState): Float = @@ -57,6 +77,7 @@ class GoogleMapRasterLayerOverlayRenderer( val prev = params.prev val next = params.current.state if (prev.state.source != next.source || prev.state.debug != next.debug) { + localRequests.remove(prev.layer)?.close() prev.layer.remove() addLayer(next) } else { @@ -69,6 +90,7 @@ class GoogleMapRasterLayerOverlayRenderer( override suspend fun onRemove(data: List>) { withContext(coroutine.coroutineContext) { data.forEach { entity -> + localRequests.remove(entity.layer)?.close() entity.layer.remove() } } @@ -98,6 +120,16 @@ class GoogleMapRasterLayerOverlayRenderer( } val requestHeaders = headerBuilder.build() + // Only this registry's local routes bypass HTTP. Remote raster + // sources retain their headers, user agent and OkHttp behaviour. + val server = if (tileSpec.template.startsWith("http://127.0.0.1:")) TileServerRegistry.get() else null + val requests = if (server != null && tileSpec.template.startsWith("${server.baseUrl}/tiles/")) { + GoogleLocalTileRequestQueue { url, cancelled -> server.renderLocalTile(url, cancelled) }.also { + it.updateZoom(holder.map.cameraPosition.zoom.toDouble()) + } + } else { + null + } val provider = object : TileProvider { private var firstTileLogged = false @@ -106,7 +138,7 @@ class GoogleMapRasterLayerOverlayRenderer( x: Int, y: Int, zoom: Int, - ): Tile { + ): Tile? { if (!firstTileLogged) { firstTileLogged = true } @@ -123,6 +155,18 @@ class GoogleMapRasterLayerOverlayRenderer( .replace("{y}", schemeY.toString()) .replace("{z}", zoom.toString()) + if (requests != null) { + // A cancelled/failed tile is retryable. NO_TILE would + // cache a permanent hole when this zoom is revisited. + val bytes = runCatching { requests.renderTile(url, zoom) }.getOrNull() ?: return null + val rendered = if (state.debug) { + addDebugOverlay(bytes, tileSpec.tileSize, x, schemeY, zoom, tileSpec.scheme) + } else { + bytes + } + return Tile(tileSpec.tileSize, tileSpec.tileSize, rendered) + } + val request: Request = Request .Builder() @@ -170,6 +214,9 @@ class GoogleMapRasterLayerOverlayRenderer( .transparency(opacityToTransparency(state.opacity)) .visible(state.visible) val overlay = holder.map.addTileOverlay(options) + if (requests != null) { + if (overlay != null) localRequests[overlay] = requests else requests.close() + } return overlay } diff --git a/src/test/java/com/mapconductor/googlemaps/raster/GoogleLocalTileRequestQueueTest.kt b/src/test/java/com/mapconductor/googlemaps/raster/GoogleLocalTileRequestQueueTest.kt new file mode 100644 index 0000000..67c0f49 --- /dev/null +++ b/src/test/java/com/mapconductor/googlemaps/raster/GoogleLocalTileRequestQueueTest.kt @@ -0,0 +1,100 @@ +package com.mapconductor.googlemaps.raster + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class GoogleLocalTileRequestQueueTest { + @Test + fun obsoletePendingRequestsReturnBeforeTheActiveRenderFinishes() { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val queue = GoogleLocalTileRequestQueue(width = 1) { _, _ -> entered.countDown(); release.await(); byteArrayOf(1) } + val workers = Executors.newFixedThreadPool(2) + try { + queue.updateZoom(12.0) + val owner = workers.submit { queue.renderTile("owner", 12) } + assertTrue(entered.await(1, TimeUnit.SECONDS)) + val pending = workers.submit { queue.renderTile("pending", 12) } + queue.updateZoom(16.0) + assertNull(pending.get(1, TimeUnit.SECONDS)) + assertFalse(owner.isDone) + assertTrue(queue.consumeRetryAfterZoom()) + assertFalse(queue.consumeRetryAfterZoom()) + assertNull(queue.renderTile("obsolete-arrival", 12)) + release.countDown() + assertNull(owner.get(1, TimeUnit.SECONDS)) + } finally { + release.countDown(); queue.close(); workers.shutdownNow() + } + } + + @Test + fun currentZoomPreemptsAnAdjacentFallbackWhenEverySlotIsOccupied() { + val entered = CountDownLatch(1) + val queue = GoogleLocalTileRequestQueue(width = 1) { url, cancelled -> + if (url == "fallback") { + entered.countDown() + while (!cancelled()) Thread.sleep(1) + } + url.toByteArray() + } + val workers = Executors.newFixedThreadPool(2) + try { + queue.updateZoom(12.0) + val fallback = workers.submit { queue.renderTile("fallback", 11) } + assertTrue(entered.await(1, TimeUnit.SECONDS)) + val current = workers.submit { queue.renderTile("current", 12) } + assertEquals("current", current.get(1, TimeUnit.SECONDS)!!.decodeToString()) + assertNull(fallback.get(1, TimeUnit.SECONDS)) + } finally { + queue.close(); workers.shutdownNow() + } + } + + @Test + fun concurrentSdkCallbacksRemainBoundedAndKeepTheirCoordinates() { + val active = AtomicInteger() + val peak = AtomicInteger() + val queue = GoogleLocalTileRequestQueue(width = 3) { url, _ -> + peak.accumulateAndGet(active.incrementAndGet(), ::maxOf) + try { Thread.sleep(2); url.toByteArray() } finally { active.decrementAndGet() } + } + val workers = Executors.newFixedThreadPool(16) + try { + queue.updateZoom(12.0) + val results = (0 until 200).map { id -> workers.submit { queue.renderTile("12/$id/1612", 12) } } + results.forEachIndexed { id, result -> assertEquals("12/$id/1612", result.get(5, TimeUnit.SECONDS)!!.decodeToString()) } + assertTrue(peak.get() in 1..3) + assertEquals(0, active.get()) + } finally { + queue.close(); workers.shutdownNow() + } + } + + @Test + fun removingTheLayerRejectsNewRequestsAndCancelsTheActiveOne() { + val entered = CountDownLatch(1) + val queue = GoogleLocalTileRequestQueue { _, cancelled -> + entered.countDown() + while (!cancelled()) Thread.sleep(1) + byteArrayOf(1) + } + val worker = Executors.newSingleThreadExecutor() + try { + val running = worker.submit { queue.renderTile("active", 12) } + assertTrue(entered.await(1, TimeUnit.SECONDS)) + queue.close() + assertNull(running.get(1, TimeUnit.SECONDS)) + assertNull(queue.renderTile("after-close", 12)) + } finally { + queue.close(); worker.shutdownNow() + } + } +}