diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index e3ca8f0..cbc7bb1 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -2611,6 +2611,7 @@ public abstract class com.mapconductor.core.raster.RasterLayerController); public final java.lang.Object removeById(java.lang.String, kotlin.coroutines.Continuation); public java.lang.Object clear(kotlin.coroutines.Continuation); + public java.lang.Object reapplyStyle(kotlin.coroutines.Continuation); public com.mapconductor.core.raster.RasterLayerEntityInterface find(com.mapconductor.core.features.GeoPointInterface); public java.lang.Object onCameraChanged(com.mapconductor.core.map.MapCameraPosition, kotlin.coroutines.Continuation); public void destroy(); diff --git a/src/main/java/com/mapconductor/core/raster/RasterLayerController.kt b/src/main/java/com/mapconductor/core/raster/RasterLayerController.kt index 29d9205..4b3d831 100644 --- a/src/main/java/com/mapconductor/core/raster/RasterLayerController.kt +++ b/src/main/java/com/mapconductor/core/raster/RasterLayerController.kt @@ -242,6 +242,37 @@ abstract class RasterLayerController( } } + /** + * Re-adds every registered layer after the style was replaced, which drops + * the sources and layers the SDK added. + * + * It runs under the same lock as add/update/upsert/clear, so it reads the + * manager only after any change in progress has registered its entity. Read + * outside the lock, a change between renderer.onChange() and registerEntity() + * would be re-applied with its old source or opacity (react-sdk hit this: + * MapConductor/js-sdk-core#15). + * + * Entities are not registered again: the providers that call this build + * their handles from the layer id, so the registered ones stay valid on the + * new style. + */ + open suspend fun reapplyStyle() { + withContext(renderer.coroutine.coroutineContext) { + semaphore.withPermit { + val states = rasterLayerManager.allEntities().map { it.state } + if (states.isEmpty()) return@withPermit + renderer.onAdd( + states.map { state -> + object : RasterLayerOverlayRendererInterface.AddParamsInterface { + override val state: RasterLayerState = state + } + }, + ) + renderer.onPostProcess() + } + } + } + override fun find(position: GeoPointInterface): RasterLayerEntityInterface? = null override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { diff --git a/src/test/java/com/mapconductor/core/raster/RasterLayerReapplyStyleTest.kt b/src/test/java/com/mapconductor/core/raster/RasterLayerReapplyStyleTest.kt new file mode 100644 index 0000000..a98c91c --- /dev/null +++ b/src/test/java/com/mapconductor/core/raster/RasterLayerReapplyStyleTest.kt @@ -0,0 +1,185 @@ +package com.mapconductor.core.raster + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest + +/** + * `reapplyStyle()` runs under the controller lock. + * + * The renderer here only remembers what it last put on the map, and can be + * held inside onChange()/onRemove(): the window between the renderer call and + * the manager update that react-sdk's resync() fell into (js-sdk-core#15). + * Android's renderers do not suspend there today, so this guards the lock, not + * an interleaving that happens now. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class RasterLayerReapplyStyleTest { + private data class Handle( + val id: String, + val generation: Int, + ) + + private class FakeRenderer( + override val coroutine: CoroutineScope, + ) : RasterLayerOverlayRendererInterface { + /** id -> the state last put on the map. */ + val onMap = mutableMapOf() + var hold: CompletableDeferred? = null + private var generation = 0 + + override suspend fun onAdd(data: List): List = + data.map { params -> + onMap[params.state.id] = params.state.copy() + Handle(params.state.id, ++generation) + } + + override suspend fun onChange( + data: List>, + ): List { + val handles = + data.map { params -> + onMap[params.current.state.id] = params.current.state.copy() + params.prev.layer + } + hold?.await() + return handles + } + + override suspend fun onRemove(data: List>) { + data.forEach { onMap.remove(it.state.id) } + hold?.await() + } + + override suspend fun onPostProcess() {} + } + + private class TestController( + renderer: FakeRenderer, + ) : RasterLayerController(RasterLayerManager(), renderer) + + private fun state( + version: String, + opacity: Float = 1.0f, + ) = RasterLayerState( + id = "tiles", + source = RasterLayerSource.UrlTemplate(template = "https://example.com/$version/{z}/{x}/{y}.png"), + opacity = opacity, + ) + + private fun TestScope.fixture(): Pair { + val renderer = FakeRenderer(CoroutineScope(StandardTestDispatcher(testScheduler))) + return TestController(renderer) to renderer + } + + /** Starts [change], holds it inside the renderer, then starts reapplyStyle() and lets both finish. */ + private suspend fun TestScope.reapplyDuring( + renderer: FakeRenderer, + controller: TestController, + change: suspend () -> Unit, + ) { + val hold = CompletableDeferred() + renderer.hold = hold + val changing = launch { change() } + runCurrent() + val reapplying = launch { controller.reapplyStyle() } + runCurrent() + renderer.hold = null + hold.complete(Unit) + advanceUntilIdle() + changing.join() + reapplying.join() + } + + @Test + fun `reapplyStyle during a source update keeps the new source`() = + runTest { + val (controller, renderer) = fixture() + controller.add(listOf(state("circle"))) + val numbered = state("numbered") + + reapplyDuring(renderer, controller) { controller.update(numbered) } + + assertEquals(numbered.source, renderer.onMap["tiles"]?.source) + assertEquals( + numbered.source, + controller.rasterLayerManager + .getEntity("tiles") + ?.state + ?.source, + ) + } + + @Test + fun `reapplyStyle during an opacity update keeps the new opacity`() = + runTest { + val (controller, renderer) = fixture() + controller.add(listOf(state("circle", opacity = 1.0f))) + + reapplyDuring(renderer, controller) { controller.update(state("circle", opacity = 0.5f)) } + + assertEquals(0.5f, renderer.onMap["tiles"]?.opacity) + assertEquals( + 0.5f, + controller.rasterLayerManager + .getEntity("tiles") + ?.state + ?.opacity, + ) + } + + @Test + fun `reapplyStyle during a composition keeps the new source`() = + runTest { + val (controller, renderer) = fixture() + controller.add(listOf(state("circle"))) + val numbered = state("numbered") + + reapplyDuring(renderer, controller) { controller.add(listOf(numbered)) } + + assertEquals(numbered.source, renderer.onMap["tiles"]?.source) + assertEquals( + numbered.source, + controller.rasterLayerManager + .getEntity("tiles") + ?.state + ?.source, + ) + } + + @Test + fun `reapplyStyle cannot resurrect a layer removed by clear`() = + runTest { + val (controller, renderer) = fixture() + controller.add(listOf(state("circle"))) + + reapplyDuring(renderer, controller) { controller.clear() } + + assertNull(renderer.onMap["tiles"]) + assertNull(controller.rasterLayerManager.getEntity("tiles")) + } + + @Test + fun `reapplyStyle puts the layers back without registering them again`() = + runTest { + val (controller, renderer) = fixture() + controller.add(listOf(state("circle"))) + val registered = controller.rasterLayerManager.getEntity("tiles") + renderer.onMap.clear() // the style reload dropped it + + controller.reapplyStyle() + + assertEquals(state("circle").source, renderer.onMap["tiles"]?.source) + assertSame(registered, controller.rasterLayerManager.getEntity("tiles")) + } +}