diff --git a/src/androidTest/java/com/mapconductor/core/map/MapViewSaverRoundTripTest.kt b/src/androidTest/java/com/mapconductor/core/map/MapViewSaverRoundTripTest.kt new file mode 100644 index 0000000..f90d0c1 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/map/MapViewSaverRoundTripTest.kt @@ -0,0 +1,216 @@ +package com.mapconductor.core.map + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import android.os.Bundle +import android.os.Parcel + +/** + * 回転などの構成変更で state が Saver を通って作り直されるときに、値が落ちないこと。 + * + * [Bundle] は JVM の単体テストでは中身の無いスタブなので、実機で確かめる。保存した + * Bundle は一度 [Parcel] に書いて読み戻す(プロセスが落ちたあとの復元と同じ経路で、 + * Bundle に入れてはいけない型が混じっていればここで落ちる)。 + */ +@RunWith(AndroidJUnit4::class) +class MapViewSaverRoundTripTest { + private val rules = + listOf( + AttributionRule("© plain"), + AttributionRule( + attribution = "© ranged", + minZoom = 3, + maxZoom = 12, + bounds = GeoRectBounds(GeoPoint(35.0, 139.0), GeoPoint(36.0, 140.5)), + ), + AttributionRule(attribution = "© empty bounds", bounds = GeoRectBounds()), + ) + + @Test + fun attributionRulesSurviveParcel() { + val bundle = Bundle() + MapViewStateBundle.putAttributionRules(bundle, rules) + + val restored = MapViewStateBundle.getAttributionRules(throughParcel(bundle)) + + assertEquals(rules.size, restored.size) + rules.zip(restored).forEach { (expected, actual) -> + assertEquals(expected.attribution, actual.attribution) + assertEquals(expected.minZoom, actual.minZoom) + assertEquals(expected.maxZoom, actual.maxZoom) + assertEquals(expected.bounds?.southWest, actual.bounds?.southWest) + assertEquals(expected.bounds?.northEast, actual.bounds?.northEast) + assertEquals(expected.bounds?.isEmpty, actual.bounds?.isEmpty) + } + } + + @Test + fun missingAttributionRulesReadAsEmpty() { + assertEquals(emptyList(), MapViewStateBundle.getAttributionRules(Bundle())) + assertEquals(emptyList(), MapViewStateBundle.getAttributionRules(null)) + } + + @Test + fun saverKeepsUiSettingsCameraAndDesign() { + val state = + FakeState( + id = "state-1", + design = FakeDesign("custom-design", rules), + camera = + MapCameraPosition( + position = GeoPoint(35.68, 139.76, 12.0), + zoom = 14.5, + bearing = 30.0, + tilt = 45.0, + paddings = MapPaddings(top = 10.0, left = 20.0, bottom = 30.0, right = 40.0), + ), + ) + state.uiSettings = + MapUISettings( + scrollGesture = false, + zoomGesture = true, + rotateGesture = false, + tiltGesture = false, + paddings = MapPaddings(top = 1.0, left = 2.0, bottom = 3.0, right = 4.0), + ) + + val restored = roundTrip(FakeSaver().createSaver(), state) + + assertEquals("state-1", restored.id) + assertEquals("custom-design", restored.mapDesignType.id) + assertEquals(rules.map { it.attribution }, restored.mapDesignType.attributionRules.map { it.attribution }) + + val ui = restored.uiSettings + assertEquals(false, ui.scrollGesture) + assertEquals(true, ui.zoomGesture) + assertEquals(false, ui.rotateGesture) + assertEquals(false, ui.tiltGesture) + assertPaddings(MapPaddings(1.0, 2.0, 3.0, 4.0), ui.paddings) + + val camera = restored.cameraPosition + assertEquals(35.68, camera.position.latitude, 1e-9) + assertEquals(139.76, camera.position.longitude, 1e-9) + assertEquals(12.0, camera.position.altitude, 1e-9) + assertEquals(14.5, camera.zoom, 1e-9) + assertEquals(30.0, camera.bearing, 1e-9) + assertEquals(45.0, camera.tilt, 1e-9) + assertPaddings(MapPaddings(10.0, 20.0, 30.0, 40.0), camera.paddings!!) + } + + @Test + fun defaultUiSettingsStayDefault() { + val restored = roundTrip(FakeSaver().createSaver(), FakeState("s", FakeDesign("d", emptyList()))) + assertEquals(MapUISettings.Default.scrollGesture, restored.uiSettings.scrollGesture) + assertEquals(MapUISettings.Default.rotateGesture, restored.uiSettings.rotateGesture) + assertEquals(emptyList(), restored.mapDesignType.attributionRules) + } + + /** 以前の形式(uiSettings も camera の paddings も無い)の Bundle も読めること。 */ + @Test + fun readsBundleWrittenBeforeTheFix() { + val legacy = + Bundle().apply { + putString("stateId", "old") + putBundle("mapDesign", Bundle().apply { putString("id", "old-design") }) + putBundle( + "camera", + Bundle().apply { + putDouble("zoom", 5.0) + putDouble("tilt", 0.0) + putDouble("bearing", 0.0) + putDouble("latitude", 1.0) + putDouble("longitude", 2.0) + }, + ) + } + + val restored = FakeSaver().createSaver().restore(throughParcel(legacy))!! + + assertEquals("old", restored.id) + assertEquals("old-design", restored.mapDesignType.id) + assertEquals(MapUISettings.Default, restored.uiSettings) + assertEquals(5.0, restored.cameraPosition.zoom, 1e-9) + assertNull(restored.cameraPosition.paddings) + } + + private fun assertPaddings( + expected: MapPaddingsInterface, + actual: MapPaddingsInterface, + ) { + assertEquals(expected.top, actual.top, 1e-9) + assertEquals(expected.left, actual.left, 1e-9) + assertEquals(expected.bottom, actual.bottom, 1e-9) + assertEquals(expected.right, actual.right, 1e-9) + } + + private class FakeDesign( + override val id: String, + override val attributionRules: List, + ) : MapDesignTypeInterface { + override fun getValue(): String = id + } + + private class FakeState( + override val id: String, + design: FakeDesign, + camera: MapCameraPosition = MapCameraPosition.Default, + ) : MapViewState(camera) { + override var mapDesignType: FakeDesign = design + } + + private class FakeSaver : BaseMapViewSaver() { + override fun saveMapDesign( + state: FakeState, + bundle: Bundle, + ) { + bundle.putString("id", state.mapDesignType.id) + MapViewStateBundle.putAttributionRules(bundle, state.mapDesignType.attributionRules) + } + + override fun createState( + stateId: String, + mapDesignBundle: Bundle?, + cameraPosition: MapCameraPosition, + ): FakeState = + FakeState( + id = stateId, + design = + FakeDesign( + id = mapDesignBundle?.getString("id") ?: "", + attributionRules = MapViewStateBundle.getAttributionRules(mapDesignBundle), + ), + camera = cameraPosition, + ) + + override fun getStateId(state: FakeState): String = state.id + } + + private companion object { + fun throughParcel(bundle: Bundle): Bundle { + val parcel = Parcel.obtain() + try { + parcel.writeBundle(bundle) + parcel.setDataPosition(0) + return parcel.readBundle(MapViewSaverRoundTripTest::class.java.classLoader)!! + } finally { + parcel.recycle() + } + } + + fun roundTrip( + saver: Saver, + value: T, + ): T { + val scope = SaverScope { true } + val saved = with(saver) { scope.save(value) }!! + return saver.restore(throughParcel(saved))!! + } + } +} diff --git a/src/main/java/com/mapconductor/core/map/BaseMapViewSaver.kt b/src/main/java/com/mapconductor/core/map/BaseMapViewSaver.kt index cc858e8..7adc107 100644 --- a/src/main/java/com/mapconductor/core/map/BaseMapViewSaver.kt +++ b/src/main/java/com/mapconductor/core/map/BaseMapViewSaver.kt @@ -1,13 +1,21 @@ package com.mapconductor.core.map import androidx.compose.runtime.saveable.Saver -import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.InternalMapConductorApi import android.os.Bundle /** * Base class for MapView state savers + * + * カメラ(paddings を含む)と [MapViewStateInterface.uiSettings] はここで保存・復元する。 + * デザインは型がプロバイダごとに違うので [saveMapDesign] / [createState] が受け持つ。 + * デザインの `attributionRules` を含め、アプリが設定したものは全部書くこと + * (`MapViewStateBundle.putAttributionRules` を使う)。書かなかった値は回転などの + * 構成変更のあと黙って既定値に戻る。 + * * @param T MapViewStateInterface type */ +@OptIn(InternalMapConductorApi::class) abstract class BaseMapViewSaver> { /** * Save map design type to bundle @@ -47,6 +55,7 @@ abstract class BaseMapViewSaver> { putString("stateId", getStateId(state)) putBundle("mapDesign", mapDesignBundle) putBundle("camera", cameraStateBundle) + putBundle(MapViewStateBundle.KEY_UI_SETTINGS, MapViewStateBundle.uiSettingsToBundle(state.uiSettings)) } }, restore = { storedData -> @@ -56,7 +65,14 @@ abstract class BaseMapViewSaver> { val cameraPosition = createCameraPositionFromBundle(cameraBundle) - createState(stateId, mapDesignBundle, cameraPosition) + createState(stateId, mapDesignBundle, cameraPosition).also { state -> + // uiSettings はどのプロバイダでも MapViewStateInterface の var なので、 + // createState に渡さずここで戻す(以前は保存しておらず、復元後は + // 全部 true の既定値に戻っていた)。 + MapViewStateBundle.uiSettingsFromBundle(storedData.getBundle(MapViewStateBundle.KEY_UI_SETTINGS))?.let { + state.uiSettings = it + } + } }, ) @@ -65,27 +81,8 @@ abstract class BaseMapViewSaver> { */ protected abstract fun getStateId(state: T): String - private fun createCameraBundle(state: T): Bundle = - state.cameraPosition.let { - Bundle().apply { - putDouble("zoom", it.zoom) - putDouble("tilt", it.tilt) - putDouble("bearing", it.bearing) - putDouble("latitude", it.position.latitude) - putDouble("longitude", it.position.longitude) - } - } + private fun createCameraBundle(state: T): Bundle = MapViewStateBundle.cameraToBundle(state.cameraPosition) private fun createCameraPositionFromBundle(cameraBundle: Bundle?): MapCameraPosition = - MapCameraPosition( - position = - GeoPoint.fromLatLong( - latitude = cameraBundle?.getDouble("latitude") ?: 0.0, - longitude = cameraBundle?.getDouble("longitude") ?: 0.0, - ), - zoom = cameraBundle?.getDouble("zoom") ?: 0.0, - bearing = cameraBundle?.getDouble("bearing") ?: 0.0, - tilt = cameraBundle?.getDouble("tilt") ?: 0.0, - paddings = getCameraPaddings(), - ) + MapViewStateBundle.cameraFromBundle(cameraBundle, fallbackPaddings = getCameraPaddings()) } diff --git a/src/main/java/com/mapconductor/core/map/MapViewStateBundle.kt b/src/main/java/com/mapconductor/core/map/MapViewStateBundle.kt new file mode 100644 index 0000000..991cf0a --- /dev/null +++ b/src/main/java/com/mapconductor/core/map/MapViewStateBundle.kt @@ -0,0 +1,191 @@ +package com.mapconductor.core.map + +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoRectBounds +import android.os.Bundle + +/** + * `rememberSaveable` の Saver([BaseMapViewSaver] とプロバイダごとの `*MapViewSaver`)が + * state を [Bundle] へ書き出し、読み戻すための共通部品。 + * + * ## なぜ要るか + * + * 画面回転などの構成変更で Activity が作り直されると、state は Saver が [Bundle] に + * 書いたものからだけ作り直される。書かなかった値は**黙って既定値に戻る** + * (デザインの attributionRules が消える、uiSettings が全部 true に戻る、など)。 + * どのプロバイダも同じ値を同じ形で書けるよう、ここにまとめる。 + * + * ## 値の形 + * + * [Bundle] にはプリミティブと入れ子の [Bundle] だけを入れる。`Serializable` を使わない + * のは、プロセスが落ちたあとの復元でもクラスローダーに依存せず読めるようにするため。 + * リストは「件数 + 添字をキーにした子 Bundle」で表す。 + */ +@InternalMapConductorApi +object MapViewStateBundle { + /** プロバイダの design Bundle で attributionRules を入れるキー。 */ + const val KEY_ATTRIBUTION_RULES: String = "attributionRules" + + /** [BaseMapViewSaver] が uiSettings を入れるキー。 */ + const val KEY_UI_SETTINGS: String = "uiSettings" + + // ---- AttributionRule ------------------------------------------------- + + fun putAttributionRules( + bundle: Bundle, + rules: List, + key: String = KEY_ATTRIBUTION_RULES, + ) { + bundle.putBundle(key, attributionRulesToBundle(rules)) + } + + /** 保存されていなければ空リスト。 */ + fun getAttributionRules( + bundle: Bundle?, + key: String = KEY_ATTRIBUTION_RULES, + ): List = attributionRulesFromBundle(bundle?.getBundle(key)) + + fun attributionRulesToBundle(rules: List): Bundle = + Bundle().apply { + putInt(KEY_COUNT, rules.size) + rules.forEachIndexed { index, rule -> + putBundle(index.toString(), attributionRuleToBundle(rule)) + } + } + + fun attributionRulesFromBundle(bundle: Bundle?): List { + if (bundle == null) return emptyList() + val count = bundle.getInt(KEY_COUNT, 0) + return (0 until count).mapNotNull { index -> + attributionRuleFromBundle(bundle.getBundle(index.toString())) + } + } + + private fun attributionRuleToBundle(rule: AttributionRule): Bundle = + Bundle().apply { + putString("attribution", rule.attribution) + rule.minZoom?.let { putInt("minZoom", it) } + rule.maxZoom?.let { putInt("maxZoom", it) } + rule.bounds?.let { putBundle("bounds", boundsToBundle(it)) } + } + + private fun attributionRuleFromBundle(bundle: Bundle?): AttributionRule? { + val attribution = bundle?.getString("attribution") ?: return null + return AttributionRule( + attribution = attribution, + minZoom = if (bundle.containsKey("minZoom")) bundle.getInt("minZoom") else null, + maxZoom = if (bundle.containsKey("maxZoom")) bundle.getInt("maxZoom") else null, + bounds = bundle.getBundle("bounds")?.let(::boundsFromBundle), + ) + } + + private fun boundsToBundle(bounds: GeoRectBounds): Bundle = + Bundle().apply { + bounds.southWest?.let { putBundle("southWest", pointToBundle(it)) } + bounds.northEast?.let { putBundle("northEast", pointToBundle(it)) } + } + + private fun boundsFromBundle(bundle: Bundle): GeoRectBounds = + GeoRectBounds( + southWest = bundle.getBundle("southWest")?.let(::pointFromBundle), + northEast = bundle.getBundle("northEast")?.let(::pointFromBundle), + ) + + private fun pointToBundle(point: GeoPoint): Bundle = + Bundle().apply { + putDouble("latitude", point.latitude) + putDouble("longitude", point.longitude) + putDouble("altitude", point.altitude) + } + + private fun pointFromBundle(bundle: Bundle): GeoPoint = + GeoPoint( + latitude = bundle.getDouble("latitude"), + longitude = bundle.getDouble("longitude"), + altitude = bundle.getDouble("altitude"), + ) + + // ---- MapPaddings ------------------------------------------------------ + + fun paddingsToBundle(paddings: MapPaddingsInterface): Bundle = + Bundle().apply { + putDouble("top", paddings.top) + putDouble("left", paddings.left) + putDouble("bottom", paddings.bottom) + putDouble("right", paddings.right) + } + + fun paddingsFromBundle(bundle: Bundle?): MapPaddings? { + if (bundle == null) return null + return MapPaddings( + top = bundle.getDouble("top"), + left = bundle.getDouble("left"), + bottom = bundle.getDouble("bottom"), + right = bundle.getDouble("right"), + ) + } + + // ---- MapUISettings ---------------------------------------------------- + + fun uiSettingsToBundle(settings: MapUISettings): Bundle = + Bundle().apply { + putBoolean("scrollGesture", settings.scrollGesture) + putBoolean("zoomGesture", settings.zoomGesture) + putBoolean("rotateGesture", settings.rotateGesture) + putBoolean("tiltGesture", settings.tiltGesture) + putBundle("paddings", paddingsToBundle(settings.paddings)) + } + + /** 保存されていなければ null(呼び出し側は state の既定値をそのまま使う)。 */ + fun uiSettingsFromBundle(bundle: Bundle?): MapUISettings? { + if (bundle == null) return null + return MapUISettings( + scrollGesture = bundle.getBoolean("scrollGesture", true), + zoomGesture = bundle.getBoolean("zoomGesture", true), + rotateGesture = bundle.getBoolean("rotateGesture", true), + tiltGesture = bundle.getBoolean("tiltGesture", true), + paddings = paddingsFromBundle(bundle.getBundle("paddings")) ?: MapPaddings.Zeros, + ) + } + + // ---- MapCameraPosition ----------------------------------------------- + + /** + * カメラを書き出す。キー(zoom / tilt / bearing / latitude / longitude)は + * 以前の [BaseMapViewSaver] と同じなので、古い Bundle もそのまま読める。 + * `visibleRegion` は地図が描画のたびに計算し直すので保存しない。 + */ + fun cameraToBundle(camera: MapCameraPositionInterface): Bundle = + Bundle().apply { + putDouble("zoom", camera.zoom) + putDouble("tilt", camera.tilt) + putDouble("bearing", camera.bearing) + putDouble("latitude", camera.position.latitude) + putDouble("longitude", camera.position.longitude) + putDouble("altitude", camera.position.altitude ?: 0.0) + camera.paddings?.let { putBundle("paddings", paddingsToBundle(it)) } + } + + /** + * @param fallbackPaddings paddings が保存されていなかったとき(以前の形式の Bundle)に使う値。 + */ + fun cameraFromBundle( + bundle: Bundle?, + fallbackPaddings: MapPaddingsInterface? = null, + ): MapCameraPosition = + MapCameraPosition( + position = + GeoPoint( + latitude = bundle?.getDouble("latitude") ?: 0.0, + longitude = bundle?.getDouble("longitude") ?: 0.0, + altitude = bundle?.getDouble("altitude") ?: 0.0, + ), + zoom = bundle?.getDouble("zoom") ?: 0.0, + bearing = bundle?.getDouble("bearing") ?: 0.0, + tilt = bundle?.getDouble("tilt") ?: 0.0, + paddings = paddingsFromBundle(bundle?.getBundle("paddings")) ?: fallbackPaddings, + ) + + private const val KEY_COUNT = "count" +}