From 5aba473b3731ff37ae8b3ee957a80f22e142372d Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 10 Oct 2026 01:19:05 -0700 Subject: [PATCH 1/3] fix: cancel obsolete Google local tile requests --- api/android-for-googlemaps.api | 2 + .../GoogleMapViewControllerCamera.kt | 1 + .../raster/GoogleLocalTileRequestQueue.kt | 99 +++++++++++++++++ .../raster/GoogleMapRasterLayerController.kt | 19 +++- .../GoogleMapRasterLayerOverlayRenderer.kt | 49 ++++++++- .../raster/GoogleLocalTileRequestQueueTest.kt | 100 ++++++++++++++++++ 6 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/mapconductor/googlemaps/raster/GoogleLocalTileRequestQueue.kt create mode 100644 src/test/java/com/mapconductor/googlemaps/raster/GoogleLocalTileRequestQueueTest.kt diff --git a/api/android-for-googlemaps.api b/api/android-for-googlemaps.api index 40a1ff6..411ade5 100644 --- a/api/android-for-googlemaps.api +++ b/api/android-for-googlemaps.api @@ -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/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() + } + } +} From 330f60302e28def50913313d90a24862d3322ac1 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 10 Oct 2026 14:18:52 -0700 Subject: [PATCH 2/3] feat: integrate vector style support and pending map fixes --- api/android-for-googlemaps.api | 2 +- .../mapconductor/googlemaps/GoogleMapView.kt | 664 +++++++++--------- 2 files changed, 339 insertions(+), 327 deletions(-) diff --git a/api/android-for-googlemaps.api b/api/android-for-googlemaps.api index 411ade5..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 { diff --git a/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt b/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt index 64869fb..bf3d4a6 100644 --- a/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt +++ b/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt @@ -1,326 +1,338 @@ -package com.mapconductor.googlemaps - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.google.android.gms.maps.GoogleMapOptions -import com.google.android.gms.maps.MapView -import com.mapconductor.compose.map.MapViewBase -import com.mapconductor.core.OnCameraMoveHandler -import com.mapconductor.core.OnMapEventHandler -import com.mapconductor.core.OnMapLoadedHandler -import com.mapconductor.core.map.CameraRestriction -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.MutableMapServiceRegistry -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -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.googlemaps.circle.GoogleMapCircleController -import com.mapconductor.googlemaps.circle.GoogleMapCircleOverlayRenderer -import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageController -import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageOverlayRenderer -import com.mapconductor.googlemaps.marker.GoogleMapMarkerController -import com.mapconductor.googlemaps.polygon.GoogleMapPolygonController -import com.mapconductor.googlemaps.polygon.GoogleMapPolygonOverlayRenderer -import com.mapconductor.googlemaps.polyline.GoogleMapPolylineController -import com.mapconductor.googlemaps.polyline.GoogleMapPolylineOverlayRenderer -import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerController -import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer -import okhttp3.OkHttpClient -import android.view.ViewGroup -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.suspendCancellableCoroutine - -@OptIn(ExperimentalCoroutinesApi::class) -@Composable -fun GoogleMapView( - state: GoogleMapViewState, - modifier: Modifier = Modifier, - markerTiling: MarkerTilingOptions? = null, - cameraRestriction: CameraRestriction? = null, - sdkInitialize: (suspend (android.content.Context) -> Boolean)? = null, - onMapLoaded: OnMapLoadedHandler? = null, - onMapClick: OnMapEventHandler? = null, - onMapLongClick: OnMapEventHandler? = null, - onCameraMoveStart: OnCameraMoveHandler? = null, - onCameraMove: OnCameraMoveHandler? = null, - onCameraMoveEnd: OnCameraMoveHandler? = null, - content: (@Composable GoogleMapViewScope.() -> Unit)? = null, -) { - val scope = remember { GoogleMapViewScope() } // Use specific scope - val context = LocalContext.current // Context will be available from MapViewBase too if needed - val registry = remember { scope.buildRegistry() } - val cameraState = remember { mutableStateOf(state.cameraPosition) } - - MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = { - val cameraPosition = - MapCameraPosition - .from(state.cameraPosition) - .toCameraPosition() - - val mapInitOptions = - GoogleMapOptions() - .mapType(state.mapDesignType.getValue()) - .camera(cameraPosition) - - MapView(context, mapInitOptions).apply { - onCreate(null) - } - }, - holderProvider = { mapView -> - - suspendCancellableCoroutine { cont -> - mapView.getMapAsync { map -> - val holder = GoogleMapViewHolder(mapView, map) - cont.resume(holder) { _, _, _ -> } - } - } - }, - controllerProvider = { holder -> - createGoogleMapViewController( - holder = holder, - markerTiling = markerTiling ?: MarkerTilingOptions.Default, - serviceRegistry = state.serviceRegistry, - ).also { mapController -> - state.setController(mapController) - mapController.setCameraMoveStartListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveStart?.invoke(it) - } - mapController.setCameraMoveListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMove?.invoke(it) - } - mapController.setCameraMoveEndListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveEnd?.invoke(it) - } - mapController.setMapClickListener(onMapClick) - mapController.setMapLongClickListener(onMapLongClick) - mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - cameraRestriction?.let { mapController.setCameraRestriction(it) } - // Post an initial camera update once the MapView is laid out - holder.mapView.post { mapController.sendInitialCameraUpdate() } - } - }, - scope = scope, - registry = registry, - onMapLoaded = onMapLoaded, - customDisposableEffect = { _, holderRef -> - // Specific Google Maps DisposableEffect logic - val lifecycle = LocalLifecycleOwner.current.lifecycle // Get lifecycle here - DisposableEffect(lifecycle) { - val stateId = state.id - val observer = - object : DefaultLifecycleObserver { - override fun onResume(owner: LifecycleOwner) { - holderRef.value?.mapView?.onResume() - } - - override fun onPause(owner: LifecycleOwner) { - holderRef.value?.mapView?.onPause() - } - - override fun onDestroy(owner: LifecycleOwner) { - val activity = context.findActivity() - if (activity?.isChangingConfigurations == true) { - holderRef.value?.mapView?.let { - (it.parent as? ViewGroup)?.removeView(it) - it.onDestroy() - } - } else { - // Real teardown (not a configuration change): free the - // controller's overlay controllers (tile-server routes, - // marker managers) and cancel its coroutine scope. - GoogleMapViewControllerStore.get(stateId)?.destroy() - GoogleMapViewControllerStore.remove(stateId) - } - } - } - lifecycle.addObserver(observer) - onDispose { - lifecycle.removeObserver(observer) - } - } - }, - sdkInitialize = { - sdkInitialize?.invoke(context) ?: true - }, - // 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. - content = content, // This might need adjustment based on how overlays are handled - ) -} - -/** - * Creates the imperative controller graph used by both the Compose MapView and non-Compose hosts - * such as React Native. - * - * @param serviceRegistry 登録先のサービスレジストリ。Compose からは `state.serviceRegistry` を渡す - * (react-sdk / ios-sdk と同じく持ち主は state)。React Native / Cordova のような非 Compose - * ホストは state を持たないので、自前のレジストリを渡す。 - */ -fun createGoogleMapViewController( - holder: GoogleMapViewHolder, - markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, - serviceRegistry: MutableMapServiceRegistry? = null, -): GoogleMapViewController { - val rasterLayerController = getRasterLayerController(holder) - val mapController = - GoogleMapViewController( - markerController = getMarkerController(holder, markerTiling), - groundImageController = getGroundImageController(holder), - polylineController = getPolylineController(holder), - polygonController = getPolygonController(holder), - circleController = getCircleController(holder), - rasterLayerController = rasterLayerController, - holder = holder, - ) - - serviceRegistry?.let { registry -> - // Google displays each overlay tile across 256 dp regardless of its - // encoded pixel dimensions. Rasterisers must size style paint for that slot. - registry.put( - RasterTilePreferenceKey, - object : RasterTilePreference { - override val preferredTileSize: Int = 256 - }, - ) - // オーバーレイをタップに対して透過させられるか(実測 2026-08-09)。 - // Polygon / Polyline / Circle / GroundOverlay は clickable(false) で透過し、 - // OnMapClickListener が発火するのでコアのカスケードへ寄せてある。 - // ただし Marker には clickable 相当の API が無く、タップは常に消費される。 - registry.declare( - MapCapability.ClickPassthrough, - MapCapabilityStatus.Degraded( - "overlays pass taps through, but Marker has no clickable flag so marker taps " + - "are always consumed by the SDK", - ), - ) - registry.put( - MarkerRenderingSupportKey, - object : MarkerRenderingSupport { - override val mapLoadedState = mapController.mapLoadedState - - override fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() - - override fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = - mapController.createMarkerEventController(controller) - - override fun registerMarkerEventController( - controller: MarkerEventControllerInterface, - ) { - mapController.registerMarkerEventController(controller) - } - - override fun onMarkerRenderingReady() { - mapController.onMarkerRenderingReady() - } - }, - ) - } - return mapController -} - -private fun getPolygonController(holder: GoogleMapViewHolder): GoogleMapPolygonController { - val renderer = - GoogleMapPolygonOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapPolygonController( - renderer = renderer, - ) - return controller -} - -private fun getGroundImageController(holder: GoogleMapViewHolder): GoogleMapGroundImageController { - val renderer = - GoogleMapGroundImageOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapGroundImageController( - renderer = renderer, - ) - return controller -} - -private fun getCircleController(holder: GoogleMapViewHolder): GoogleMapCircleController { - val renderer = - GoogleMapCircleOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapCircleController( - renderer = renderer, - ) - return controller -} - -private fun getPolylineController(holder: GoogleMapViewHolder): GoogleMapPolylineController { - val renderer = - GoogleMapPolylineOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapPolylineController( - renderer = renderer, - ) - return controller -} - -private fun getMarkerController( - holder: GoogleMapViewHolder, - markerTiling: MarkerTilingOptions, -) = GoogleMapMarkerController.create( - holder = holder, - markerTiling = markerTiling, -) - -private fun getRasterLayerController(holder: GoogleMapViewHolder): GoogleMapRasterLayerController { - // No disk cache: tiles are served from the local in-process tile server (no network latency), - // and multiple OkHttpClient instances sharing the same cache directory can cause corruption. - val okHttpClient = OkHttpClient.Builder().build() - - val renderer = - GoogleMapRasterLayerOverlayRenderer( - holder = holder, - okHttpClient = okHttpClient, - ) - return GoogleMapRasterLayerController( - renderer = renderer, - ) -} +package com.mapconductor.googlemaps + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.android.gms.maps.GoogleMapOptions +import com.google.android.gms.maps.MapView +import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.core.OnCameraMoveHandler +import com.mapconductor.core.OnMapEventHandler +import com.mapconductor.core.OnMapLoadedHandler +import com.mapconductor.core.map.CameraRestriction +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.map.MapViewStyle +import com.mapconductor.core.map.MutableMapServiceRegistry +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +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 +import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageOverlayRenderer +import com.mapconductor.googlemaps.marker.GoogleMapMarkerController +import com.mapconductor.googlemaps.polygon.GoogleMapPolygonController +import com.mapconductor.googlemaps.polygon.GoogleMapPolygonOverlayRenderer +import com.mapconductor.googlemaps.polyline.GoogleMapPolylineController +import com.mapconductor.googlemaps.polyline.GoogleMapPolylineOverlayRenderer +import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerController +import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer +import okhttp3.OkHttpClient +import android.view.ViewGroup +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun GoogleMapView( + state: GoogleMapViewState, + modifier: Modifier = Modifier, + markerTiling: MarkerTilingOptions? = null, + cameraRestriction: CameraRestriction? = null, + sdkInitialize: (suspend (android.content.Context) -> Boolean)? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMapLongClick: OnMapEventHandler? = null, + 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 + val context = LocalContext.current // Context will be available from MapViewBase too if needed + val registry = remember { scope.buildRegistry() } + val cameraState = remember { mutableStateOf(state.cameraPosition) } + + MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = { + val cameraPosition = + MapCameraPosition + .from(state.cameraPosition) + .toCameraPosition() + + val mapInitOptions = + GoogleMapOptions() + .mapType(state.mapDesignType.getValue()) + .camera(cameraPosition) + + MapView(context, mapInitOptions).apply { + onCreate(null) + } + }, + holderProvider = { mapView -> + + suspendCancellableCoroutine { cont -> + mapView.getMapAsync { map -> + val holder = GoogleMapViewHolder(mapView, map) + cont.resume(holder) { _, _, _ -> } + } + } + }, + controllerProvider = { holder -> + createGoogleMapViewController( + holder = holder, + markerTiling = markerTiling ?: MarkerTilingOptions.Default, + serviceRegistry = state.serviceRegistry, + ).also { mapController -> + state.setController(mapController) + mapController.setCameraMoveStartListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveStart?.invoke(it) + } + mapController.setCameraMoveListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMove?.invoke(it) + } + mapController.setCameraMoveEndListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveEnd?.invoke(it) + } + mapController.setMapClickListener(onMapClick) + mapController.setMapLongClickListener(onMapLongClick) + mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + cameraRestriction?.let { mapController.setCameraRestriction(it) } + // Post an initial camera update once the MapView is laid out + holder.mapView.post { mapController.sendInitialCameraUpdate() } + } + }, + scope = scope, + registry = registry, + onMapLoaded = onMapLoaded, + customDisposableEffect = { _, holderRef -> + // Specific Google Maps DisposableEffect logic + val lifecycle = LocalLifecycleOwner.current.lifecycle // Get lifecycle here + DisposableEffect(lifecycle) { + val stateId = state.id + val observer = + object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + holderRef.value?.mapView?.onResume() + } + + override fun onPause(owner: LifecycleOwner) { + holderRef.value?.mapView?.onPause() + } + + override fun onDestroy(owner: LifecycleOwner) { + val activity = context.findActivity() + if (activity?.isChangingConfigurations == true) { + holderRef.value?.mapView?.let { + (it.parent as? ViewGroup)?.removeView(it) + it.onDestroy() + } + } else { + // Real teardown (not a configuration change): free the + // controller's overlay controllers (tile-server routes, + // marker managers) and cancel its coroutine scope. + GoogleMapViewControllerStore.get(stateId)?.destroy() + GoogleMapViewControllerStore.remove(stateId) + } + } + } + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } + }, + sdkInitialize = { + sdkInitialize?.invoke(context) ?: true + }, + // 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 + ) +} + +/** + * Creates the imperative controller graph used by both the Compose MapView and non-Compose hosts + * such as React Native. + * + * @param serviceRegistry 登録先のサービスレジストリ。Compose からは `state.serviceRegistry` を渡す + * (react-sdk / ios-sdk と同じく持ち主は state)。React Native / Cordova のような非 Compose + * ホストは state を持たないので、自前のレジストリを渡す。 + */ +fun createGoogleMapViewController( + holder: GoogleMapViewHolder, + markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, + serviceRegistry: MutableMapServiceRegistry? = null, +): GoogleMapViewController { + val rasterLayerController = getRasterLayerController(holder) + val mapController = + GoogleMapViewController( + markerController = getMarkerController(holder, markerTiling), + groundImageController = getGroundImageController(holder), + polylineController = getPolylineController(holder), + polygonController = getPolygonController(holder), + circleController = getCircleController(holder), + rasterLayerController = rasterLayerController, + holder = holder, + ) + + serviceRegistry?.let { registry -> + // Google displays each overlay tile across 256 dp regardless of its + // encoded pixel dimensions. Rasterisers must size style paint for that slot. + registry.put( + RasterTilePreferenceKey, + object : RasterTilePreference { + override val preferredTileSize: Int = 256 + }, + ) + // オーバーレイをタップに対して透過させられるか(実測 2026-08-09)。 + // Polygon / Polyline / Circle / GroundOverlay は clickable(false) で透過し、 + // OnMapClickListener が発火するのでコアのカスケードへ寄せてある。 + // ただし Marker には clickable 相当の API が無く、タップは常に消費される。 + registry.declare( + MapCapability.ClickPassthrough, + MapCapabilityStatus.Degraded( + "overlays pass taps through, but Marker has no clickable flag so marker taps " + + "are always consumed by the SDK", + ), + ) + registry.put( + MarkerRenderingSupportKey, + object : MarkerRenderingSupport { + override val mapLoadedState = mapController.mapLoadedState + + override fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() + + override fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = + mapController.createMarkerEventController(controller) + + override fun registerMarkerEventController( + controller: MarkerEventControllerInterface, + ) { + mapController.registerMarkerEventController(controller) + } + + override fun onMarkerRenderingReady() { + mapController.onMarkerRenderingReady() + } + }, + ) + } + return mapController +} + +private fun getPolygonController(holder: GoogleMapViewHolder): GoogleMapPolygonController { + val renderer = + GoogleMapPolygonOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapPolygonController( + renderer = renderer, + ) + return controller +} + +private fun getGroundImageController(holder: GoogleMapViewHolder): GoogleMapGroundImageController { + val renderer = + GoogleMapGroundImageOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapGroundImageController( + renderer = renderer, + ) + return controller +} + +private fun getCircleController(holder: GoogleMapViewHolder): GoogleMapCircleController { + val renderer = + GoogleMapCircleOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapCircleController( + renderer = renderer, + ) + return controller +} + +private fun getPolylineController(holder: GoogleMapViewHolder): GoogleMapPolylineController { + val renderer = + GoogleMapPolylineOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapPolylineController( + renderer = renderer, + ) + return controller +} + +private fun getMarkerController( + holder: GoogleMapViewHolder, + markerTiling: MarkerTilingOptions, +) = GoogleMapMarkerController.create( + holder = holder, + markerTiling = markerTiling, +) + +private fun getRasterLayerController(holder: GoogleMapViewHolder): GoogleMapRasterLayerController { + // No disk cache: tiles are served from the local in-process tile server (no network latency), + // and multiple OkHttpClient instances sharing the same cache directory can cause corruption. + val okHttpClient = OkHttpClient.Builder().build() + + val renderer = + GoogleMapRasterLayerOverlayRenderer( + holder = holder, + okHttpClient = okHttpClient, + ) + return GoogleMapRasterLayerController( + renderer = renderer, + ) +} From 4d58ca03251b132a75da57bbd5a67a1cecf4f27e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:50:04 -0700 Subject: [PATCH 3/3] Keep each file's line endings as they are on develop The checkpoint commit rewrote whole files between LF and CRLF, which buried its real changes under line-ending noise. Content is unchanged. --- .../mapconductor/googlemaps/GoogleMapView.kt | 676 +++++++++--------- 1 file changed, 338 insertions(+), 338 deletions(-) diff --git a/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt b/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt index bf3d4a6..d24b239 100644 --- a/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt +++ b/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt @@ -1,338 +1,338 @@ -package com.mapconductor.googlemaps - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.google.android.gms.maps.GoogleMapOptions -import com.google.android.gms.maps.MapView -import com.mapconductor.compose.map.MapViewBase -import com.mapconductor.core.OnCameraMoveHandler -import com.mapconductor.core.OnMapEventHandler -import com.mapconductor.core.OnMapLoadedHandler -import com.mapconductor.core.map.CameraRestriction -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.map.MapViewStyle -import com.mapconductor.core.map.MutableMapServiceRegistry -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -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 -import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageOverlayRenderer -import com.mapconductor.googlemaps.marker.GoogleMapMarkerController -import com.mapconductor.googlemaps.polygon.GoogleMapPolygonController -import com.mapconductor.googlemaps.polygon.GoogleMapPolygonOverlayRenderer -import com.mapconductor.googlemaps.polyline.GoogleMapPolylineController -import com.mapconductor.googlemaps.polyline.GoogleMapPolylineOverlayRenderer -import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerController -import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer -import okhttp3.OkHttpClient -import android.view.ViewGroup -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.suspendCancellableCoroutine - -@OptIn(ExperimentalCoroutinesApi::class) -@Composable -fun GoogleMapView( - state: GoogleMapViewState, - modifier: Modifier = Modifier, - markerTiling: MarkerTilingOptions? = null, - cameraRestriction: CameraRestriction? = null, - sdkInitialize: (suspend (android.content.Context) -> Boolean)? = null, - onMapLoaded: OnMapLoadedHandler? = null, - onMapClick: OnMapEventHandler? = null, - onMapLongClick: OnMapEventHandler? = null, - 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 - val context = LocalContext.current // Context will be available from MapViewBase too if needed - val registry = remember { scope.buildRegistry() } - val cameraState = remember { mutableStateOf(state.cameraPosition) } - - MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = { - val cameraPosition = - MapCameraPosition - .from(state.cameraPosition) - .toCameraPosition() - - val mapInitOptions = - GoogleMapOptions() - .mapType(state.mapDesignType.getValue()) - .camera(cameraPosition) - - MapView(context, mapInitOptions).apply { - onCreate(null) - } - }, - holderProvider = { mapView -> - - suspendCancellableCoroutine { cont -> - mapView.getMapAsync { map -> - val holder = GoogleMapViewHolder(mapView, map) - cont.resume(holder) { _, _, _ -> } - } - } - }, - controllerProvider = { holder -> - createGoogleMapViewController( - holder = holder, - markerTiling = markerTiling ?: MarkerTilingOptions.Default, - serviceRegistry = state.serviceRegistry, - ).also { mapController -> - state.setController(mapController) - mapController.setCameraMoveStartListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveStart?.invoke(it) - } - mapController.setCameraMoveListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMove?.invoke(it) - } - mapController.setCameraMoveEndListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveEnd?.invoke(it) - } - mapController.setMapClickListener(onMapClick) - mapController.setMapLongClickListener(onMapLongClick) - mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - cameraRestriction?.let { mapController.setCameraRestriction(it) } - // Post an initial camera update once the MapView is laid out - holder.mapView.post { mapController.sendInitialCameraUpdate() } - } - }, - scope = scope, - registry = registry, - onMapLoaded = onMapLoaded, - customDisposableEffect = { _, holderRef -> - // Specific Google Maps DisposableEffect logic - val lifecycle = LocalLifecycleOwner.current.lifecycle // Get lifecycle here - DisposableEffect(lifecycle) { - val stateId = state.id - val observer = - object : DefaultLifecycleObserver { - override fun onResume(owner: LifecycleOwner) { - holderRef.value?.mapView?.onResume() - } - - override fun onPause(owner: LifecycleOwner) { - holderRef.value?.mapView?.onPause() - } - - override fun onDestroy(owner: LifecycleOwner) { - val activity = context.findActivity() - if (activity?.isChangingConfigurations == true) { - holderRef.value?.mapView?.let { - (it.parent as? ViewGroup)?.removeView(it) - it.onDestroy() - } - } else { - // Real teardown (not a configuration change): free the - // controller's overlay controllers (tile-server routes, - // marker managers) and cancel its coroutine scope. - GoogleMapViewControllerStore.get(stateId)?.destroy() - GoogleMapViewControllerStore.remove(stateId) - } - } - } - lifecycle.addObserver(observer) - onDispose { - lifecycle.removeObserver(observer) - } - } - }, - sdkInitialize = { - sdkInitialize?.invoke(context) ?: true - }, - // 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 - ) -} - -/** - * Creates the imperative controller graph used by both the Compose MapView and non-Compose hosts - * such as React Native. - * - * @param serviceRegistry 登録先のサービスレジストリ。Compose からは `state.serviceRegistry` を渡す - * (react-sdk / ios-sdk と同じく持ち主は state)。React Native / Cordova のような非 Compose - * ホストは state を持たないので、自前のレジストリを渡す。 - */ -fun createGoogleMapViewController( - holder: GoogleMapViewHolder, - markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, - serviceRegistry: MutableMapServiceRegistry? = null, -): GoogleMapViewController { - val rasterLayerController = getRasterLayerController(holder) - val mapController = - GoogleMapViewController( - markerController = getMarkerController(holder, markerTiling), - groundImageController = getGroundImageController(holder), - polylineController = getPolylineController(holder), - polygonController = getPolygonController(holder), - circleController = getCircleController(holder), - rasterLayerController = rasterLayerController, - holder = holder, - ) - - serviceRegistry?.let { registry -> - // Google displays each overlay tile across 256 dp regardless of its - // encoded pixel dimensions. Rasterisers must size style paint for that slot. - registry.put( - RasterTilePreferenceKey, - object : RasterTilePreference { - override val preferredTileSize: Int = 256 - }, - ) - // オーバーレイをタップに対して透過させられるか(実測 2026-08-09)。 - // Polygon / Polyline / Circle / GroundOverlay は clickable(false) で透過し、 - // OnMapClickListener が発火するのでコアのカスケードへ寄せてある。 - // ただし Marker には clickable 相当の API が無く、タップは常に消費される。 - registry.declare( - MapCapability.ClickPassthrough, - MapCapabilityStatus.Degraded( - "overlays pass taps through, but Marker has no clickable flag so marker taps " + - "are always consumed by the SDK", - ), - ) - registry.put( - MarkerRenderingSupportKey, - object : MarkerRenderingSupport { - override val mapLoadedState = mapController.mapLoadedState - - override fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() - - override fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = - mapController.createMarkerEventController(controller) - - override fun registerMarkerEventController( - controller: MarkerEventControllerInterface, - ) { - mapController.registerMarkerEventController(controller) - } - - override fun onMarkerRenderingReady() { - mapController.onMarkerRenderingReady() - } - }, - ) - } - return mapController -} - -private fun getPolygonController(holder: GoogleMapViewHolder): GoogleMapPolygonController { - val renderer = - GoogleMapPolygonOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapPolygonController( - renderer = renderer, - ) - return controller -} - -private fun getGroundImageController(holder: GoogleMapViewHolder): GoogleMapGroundImageController { - val renderer = - GoogleMapGroundImageOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapGroundImageController( - renderer = renderer, - ) - return controller -} - -private fun getCircleController(holder: GoogleMapViewHolder): GoogleMapCircleController { - val renderer = - GoogleMapCircleOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapCircleController( - renderer = renderer, - ) - return controller -} - -private fun getPolylineController(holder: GoogleMapViewHolder): GoogleMapPolylineController { - val renderer = - GoogleMapPolylineOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapPolylineController( - renderer = renderer, - ) - return controller -} - -private fun getMarkerController( - holder: GoogleMapViewHolder, - markerTiling: MarkerTilingOptions, -) = GoogleMapMarkerController.create( - holder = holder, - markerTiling = markerTiling, -) - -private fun getRasterLayerController(holder: GoogleMapViewHolder): GoogleMapRasterLayerController { - // No disk cache: tiles are served from the local in-process tile server (no network latency), - // and multiple OkHttpClient instances sharing the same cache directory can cause corruption. - val okHttpClient = OkHttpClient.Builder().build() - - val renderer = - GoogleMapRasterLayerOverlayRenderer( - holder = holder, - okHttpClient = okHttpClient, - ) - return GoogleMapRasterLayerController( - renderer = renderer, - ) -} +package com.mapconductor.googlemaps + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.android.gms.maps.GoogleMapOptions +import com.google.android.gms.maps.MapView +import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.core.OnCameraMoveHandler +import com.mapconductor.core.OnMapEventHandler +import com.mapconductor.core.OnMapLoadedHandler +import com.mapconductor.core.map.CameraRestriction +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.map.MapViewStyle +import com.mapconductor.core.map.MutableMapServiceRegistry +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +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 +import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageOverlayRenderer +import com.mapconductor.googlemaps.marker.GoogleMapMarkerController +import com.mapconductor.googlemaps.polygon.GoogleMapPolygonController +import com.mapconductor.googlemaps.polygon.GoogleMapPolygonOverlayRenderer +import com.mapconductor.googlemaps.polyline.GoogleMapPolylineController +import com.mapconductor.googlemaps.polyline.GoogleMapPolylineOverlayRenderer +import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerController +import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer +import okhttp3.OkHttpClient +import android.view.ViewGroup +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun GoogleMapView( + state: GoogleMapViewState, + modifier: Modifier = Modifier, + markerTiling: MarkerTilingOptions? = null, + cameraRestriction: CameraRestriction? = null, + sdkInitialize: (suspend (android.content.Context) -> Boolean)? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMapLongClick: OnMapEventHandler? = null, + 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 + val context = LocalContext.current // Context will be available from MapViewBase too if needed + val registry = remember { scope.buildRegistry() } + val cameraState = remember { mutableStateOf(state.cameraPosition) } + + MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = { + val cameraPosition = + MapCameraPosition + .from(state.cameraPosition) + .toCameraPosition() + + val mapInitOptions = + GoogleMapOptions() + .mapType(state.mapDesignType.getValue()) + .camera(cameraPosition) + + MapView(context, mapInitOptions).apply { + onCreate(null) + } + }, + holderProvider = { mapView -> + + suspendCancellableCoroutine { cont -> + mapView.getMapAsync { map -> + val holder = GoogleMapViewHolder(mapView, map) + cont.resume(holder) { _, _, _ -> } + } + } + }, + controllerProvider = { holder -> + createGoogleMapViewController( + holder = holder, + markerTiling = markerTiling ?: MarkerTilingOptions.Default, + serviceRegistry = state.serviceRegistry, + ).also { mapController -> + state.setController(mapController) + mapController.setCameraMoveStartListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveStart?.invoke(it) + } + mapController.setCameraMoveListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMove?.invoke(it) + } + mapController.setCameraMoveEndListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveEnd?.invoke(it) + } + mapController.setMapClickListener(onMapClick) + mapController.setMapLongClickListener(onMapLongClick) + mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + cameraRestriction?.let { mapController.setCameraRestriction(it) } + // Post an initial camera update once the MapView is laid out + holder.mapView.post { mapController.sendInitialCameraUpdate() } + } + }, + scope = scope, + registry = registry, + onMapLoaded = onMapLoaded, + customDisposableEffect = { _, holderRef -> + // Specific Google Maps DisposableEffect logic + val lifecycle = LocalLifecycleOwner.current.lifecycle // Get lifecycle here + DisposableEffect(lifecycle) { + val stateId = state.id + val observer = + object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + holderRef.value?.mapView?.onResume() + } + + override fun onPause(owner: LifecycleOwner) { + holderRef.value?.mapView?.onPause() + } + + override fun onDestroy(owner: LifecycleOwner) { + val activity = context.findActivity() + if (activity?.isChangingConfigurations == true) { + holderRef.value?.mapView?.let { + (it.parent as? ViewGroup)?.removeView(it) + it.onDestroy() + } + } else { + // Real teardown (not a configuration change): free the + // controller's overlay controllers (tile-server routes, + // marker managers) and cancel its coroutine scope. + GoogleMapViewControllerStore.get(stateId)?.destroy() + GoogleMapViewControllerStore.remove(stateId) + } + } + } + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } + }, + sdkInitialize = { + sdkInitialize?.invoke(context) ?: true + }, + // 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 + ) +} + +/** + * Creates the imperative controller graph used by both the Compose MapView and non-Compose hosts + * such as React Native. + * + * @param serviceRegistry 登録先のサービスレジストリ。Compose からは `state.serviceRegistry` を渡す + * (react-sdk / ios-sdk と同じく持ち主は state)。React Native / Cordova のような非 Compose + * ホストは state を持たないので、自前のレジストリを渡す。 + */ +fun createGoogleMapViewController( + holder: GoogleMapViewHolder, + markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, + serviceRegistry: MutableMapServiceRegistry? = null, +): GoogleMapViewController { + val rasterLayerController = getRasterLayerController(holder) + val mapController = + GoogleMapViewController( + markerController = getMarkerController(holder, markerTiling), + groundImageController = getGroundImageController(holder), + polylineController = getPolylineController(holder), + polygonController = getPolygonController(holder), + circleController = getCircleController(holder), + rasterLayerController = rasterLayerController, + holder = holder, + ) + + serviceRegistry?.let { registry -> + // Google displays each overlay tile across 256 dp regardless of its + // encoded pixel dimensions. Rasterisers must size style paint for that slot. + registry.put( + RasterTilePreferenceKey, + object : RasterTilePreference { + override val preferredTileSize: Int = 256 + }, + ) + // オーバーレイをタップに対して透過させられるか(実測 2026-08-09)。 + // Polygon / Polyline / Circle / GroundOverlay は clickable(false) で透過し、 + // OnMapClickListener が発火するのでコアのカスケードへ寄せてある。 + // ただし Marker には clickable 相当の API が無く、タップは常に消費される。 + registry.declare( + MapCapability.ClickPassthrough, + MapCapabilityStatus.Degraded( + "overlays pass taps through, but Marker has no clickable flag so marker taps " + + "are always consumed by the SDK", + ), + ) + registry.put( + MarkerRenderingSupportKey, + object : MarkerRenderingSupport { + override val mapLoadedState = mapController.mapLoadedState + + override fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface = mapController.createMarkerRenderer() + + override fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = + mapController.createMarkerEventController(controller) + + override fun registerMarkerEventController( + controller: MarkerEventControllerInterface, + ) { + mapController.registerMarkerEventController(controller) + } + + override fun onMarkerRenderingReady() { + mapController.onMarkerRenderingReady() + } + }, + ) + } + return mapController +} + +private fun getPolygonController(holder: GoogleMapViewHolder): GoogleMapPolygonController { + val renderer = + GoogleMapPolygonOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapPolygonController( + renderer = renderer, + ) + return controller +} + +private fun getGroundImageController(holder: GoogleMapViewHolder): GoogleMapGroundImageController { + val renderer = + GoogleMapGroundImageOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapGroundImageController( + renderer = renderer, + ) + return controller +} + +private fun getCircleController(holder: GoogleMapViewHolder): GoogleMapCircleController { + val renderer = + GoogleMapCircleOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapCircleController( + renderer = renderer, + ) + return controller +} + +private fun getPolylineController(holder: GoogleMapViewHolder): GoogleMapPolylineController { + val renderer = + GoogleMapPolylineOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapPolylineController( + renderer = renderer, + ) + return controller +} + +private fun getMarkerController( + holder: GoogleMapViewHolder, + markerTiling: MarkerTilingOptions, +) = GoogleMapMarkerController.create( + holder = holder, + markerTiling = markerTiling, +) + +private fun getRasterLayerController(holder: GoogleMapViewHolder): GoogleMapRasterLayerController { + // No disk cache: tiles are served from the local in-process tile server (no network latency), + // and multiple OkHttpClient instances sharing the same cache directory can cause corruption. + val okHttpClient = OkHttpClient.Builder().build() + + val renderer = + GoogleMapRasterLayerOverlayRenderer( + holder = holder, + okHttpClient = okHttpClient, + ) + return GoogleMapRasterLayerController( + renderer = renderer, + ) +}