Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/android-sdk-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -2611,6 +2611,7 @@ public abstract class com.mapconductor.core.raster.RasterLayerController<ActualL
public final java.lang.Object upsert(com.mapconductor.core.raster.RasterLayerState, kotlin.coroutines.Continuation<? super kotlin.Unit>);
public final java.lang.Object removeById(java.lang.String, kotlin.coroutines.Continuation<? super kotlin.Unit>);
public java.lang.Object clear(kotlin.coroutines.Continuation<? super kotlin.Unit>);
public java.lang.Object reapplyStyle(kotlin.coroutines.Continuation<? super kotlin.Unit>);
public com.mapconductor.core.raster.RasterLayerEntityInterface<ActualLayer> find(com.mapconductor.core.features.GeoPointInterface);
public java.lang.Object onCameraChanged(com.mapconductor.core.map.MapCameraPosition, kotlin.coroutines.Continuation<? super kotlin.Unit>);
public void destroy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,37 @@ abstract class RasterLayerController<ActualLayer : Any>(
}
}

/**
* 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<ActualLayer>? = null

override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Handle> {
/** id -> the state last put on the map. */
val onMap = mutableMapOf<String, RasterLayerState>()
var hold: CompletableDeferred<Unit>? = null
private var generation = 0

override suspend fun onAdd(data: List<RasterLayerOverlayRendererInterface.AddParamsInterface>): List<Handle?> =
data.map { params ->
onMap[params.state.id] = params.state.copy()
Handle(params.state.id, ++generation)
}

override suspend fun onChange(
data: List<RasterLayerOverlayRendererInterface.ChangeParamsInterface<Handle>>,
): List<Handle?> {
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<RasterLayerEntityInterface<Handle>>) {
data.forEach { onMap.remove(it.state.id) }
hold?.await()
}

override suspend fun onPostProcess() {}
}

private class TestController(
renderer: FakeRenderer,
) : RasterLayerController<Handle>(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<TestController, FakeRenderer> {
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<Unit>()
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"))
}
}
Loading