From f37154f895aebc0a48734b6f8785da13b89355e4 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Wed, 9 Sep 2026 23:48:30 -0700 Subject: [PATCH 1/5] Upgrade MapLibre Compose location to 0.16.0 --- androidApp/build.gradle.kts | 2 +- .../screens/main/MainActivity.kt | 7 +- app/build.gradle.kts | 2 +- .../screens/main/map/MainMapFragment.kt | 72 ++++++++----------- .../components/CurrentLocationMapComponent.kt | 44 ++++++------ .../ui/util/Location.android.kt | 7 +- .../data/location/RecentLocations.kt | 4 +- .../streetcomplete/data/quest/AutoSyncer.kt | 4 +- .../streetcomplete/util/ktx/Location.kt | 12 ++-- .../data/location/RecentLocationsTest.kt | 17 +++++ .../streetcomplete/util/ktx/LocationTest.kt | 32 +++++++++ .../streetcomplete/ui/util/Location.ios.kt | 8 ++- 12 files changed, 130 insertions(+), 81 deletions(-) create mode 100644 app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 1619bbe63ad..1209b38f5bb 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -81,7 +81,7 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling:1.10.0") // location - implementation("org.maplibre.compose:location:0.15.0") + implementation("org.maplibre.compose:location:0.16.0") // Dependency Injection implementation("io.insert-koin:koin-android:4.2.2") diff --git a/androidApp/src/main/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt b/androidApp/src/main/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt index 71465e60fbf..bd5547f7f11 100644 --- a/androidApp/src/main/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt +++ b/androidApp/src/main/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt @@ -350,13 +350,12 @@ class MainActivity : } observe(locationProvider.updates(LocationRequest())) { locationEvent -> viewModel.locationState.value = when (locationEvent) { - is LocationEvent.Fix -> LocationState.UPDATING + is LocationEvent.Update -> LocationState.UPDATING is LocationEvent.Unavailable -> when (locationEvent.reason) { LocationUnavailableReason.ServicesDisabled -> LocationState.ALLOWED LocationUnavailableReason.TemporarilyUnavailable -> LocationState.SEARCHING LocationUnavailableReason.PermissionDenied -> LocationState.DENIED LocationUnavailableReason.Unsupported, - LocationUnavailableReason.Misconfigured, LocationUnavailableReason.UnexpectedFailure -> null } } @@ -490,7 +489,7 @@ class MainActivity : private fun getDisplayedPoint(): PointF? { val mapFragment = mapFragment ?: return null - val displayedPosition = mapFragment.displayedLocation?.position?.value?.toLatLon() ?: return null + val displayedPosition = mapFragment.displayedLocation?.position?.toLatLon() ?: return null return mapFragment.getPointOf(displayedPosition) } @@ -588,7 +587,7 @@ class MainActivity : viewModel.isRecordingTracks.value = false val mapFragment = mapFragment ?: return mapFragment.stopPositionTrackRecording() - val pos = mapFragment.displayedLocation?.position?.value?.toLatLon() ?: return + val pos = mapFragment.displayedLocation?.position?.toLatLon() ?: return composeNote(pos, mapFragment.recordedTracks.takeIf { it.isNotEmpty() }) } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 83c1baba945..c5ace793101 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,7 +146,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.9.1") // location - implementation("org.maplibre.compose:location:0.15.0") + implementation("org.maplibre.compose:location:0.16.0") // SQLite implementation("androidx.sqlite:sqlite:2.7.0") diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt index 29d761bd41e..64b0593224f 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt @@ -49,17 +49,17 @@ import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.style.layers.Property import org.maplibre.android.style.layers.PropertyFactory.visibility -import org.maplibre.compose.location.AndroidOrientationProvider -import org.maplibre.compose.location.Location +import org.maplibre.compose.location.AndroidHeadingProvider +import org.maplibre.compose.location.HeadingMeasurement +import org.maplibre.compose.location.HeadingProvider +import org.maplibre.compose.location.HeadingRequest import org.maplibre.compose.location.LocationEvent -import org.maplibre.compose.location.Orientation -import org.maplibre.compose.location.OrientationProvider -import org.maplibre.compose.location.PositionWithAccuracy +import org.maplibre.compose.location.LocationMeasurement import org.maplibre.spatialk.units.Bearing -import org.maplibre.spatialk.units.DMS import org.maplibre.spatialk.units.International +import org.maplibre.spatialk.units.extensions.degrees +import org.maplibre.spatialk.units.extensions.inDegrees import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.TimeSource /** This is the map shown in the main view. It manages a map that shows the quest pins, quest * geometry, overlays, tracks, location... */ @@ -75,7 +75,7 @@ class MainMapFragment : MapFragment() { private val surveyChecker: SurveyChecker by inject() private val prefs: Preferences by inject() - private lateinit var orientationProvider: OrientationProvider + private lateinit var headingProvider: HeadingProvider private var mapImages: MapImages? = null private var geometryMarkersMapComponent: GeometryMarkersMapComponent? = null @@ -106,7 +106,7 @@ class MainMapFragment : MapFragment() { private var zoomedYet = false /** The GPS position at which the user is displayed at */ - var displayedLocation: Location? = null + var displayedLocation: LocationMeasurement? = null private set /** The GPS trackpoints the user has walked */ @@ -161,15 +161,11 @@ class MainMapFragment : MapFragment() { override fun onAttach(context: Context) { super.onAttach(context) - orientationProvider = AndroidOrientationProvider( - context = context, - updateInterval = 33.milliseconds, - coroutineScope = lifecycleScope - ) + headingProvider = AndroidHeadingProvider(context) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - orientationProvider.orientation.collect { orientation -> - onCompassRotationChanged(orientation) + headingProvider.updates(HeadingRequest(33.milliseconds)).collect { heading -> + onCompassRotationChanged(heading) } } } @@ -178,9 +174,7 @@ class MainMapFragment : MapFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { - val position: PositionWithAccuracy? = - savedInstanceState.getString(DISPLAYED_POSITION)?.let { Json.decodeFromString(it) } - displayedLocation = position?.let { Location(it, timestamp = TimeSource.Monotonic.markNow()) } + displayedLocation = savedInstanceState.getString(DISPLAYED_LOCATION)?.let { Json.decodeFromString(it) } isRecordingTracks = savedInstanceState.getBoolean(TRACKS_IS_RECORDING) tracks = Json.decodeFromString(savedInstanceState.getString(TRACKS)!!) } @@ -278,7 +272,7 @@ class MainMapFragment : MapFragment() { onSelectedOverlayChanged() selectedOverlaySource.addListener(overlayListener) - locationMapComponent?.targetPositionWithAccuracy = displayedLocation?.position + locationMapComponent?.targetLocation = displayedLocation val positionsLists = tracks.map { track -> track.map { it.position } } tracksMapComponent?.setTracks(positionsLists, isRecordingTracks) @@ -291,7 +285,7 @@ class MainMapFragment : MapFragment() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putString(DISPLAYED_POSITION, Json.encodeToString(displayedLocation?.position)) + outState.putString(DISPLAYED_LOCATION, Json.encodeToString(displayedLocation)) // the amount of data one can put into a bundle is limited, let's cut off at 1000 points outState.putString(TRACKS, Json.encodeToString(tracks.takeLastNested(1000))) outState.putBoolean(TRACKS_IS_RECORDING, isRecordingTracks) @@ -333,27 +327,23 @@ class MainMapFragment : MapFragment() { return true } - private fun onCompassRotationChanged(orientation: Orientation?) { - val rotation = orientation - ?.orientation - ?.value - ?.clockwiseRotationTo(Bearing.North) - ?.toDouble(DMS.Degrees) - locationMapComponent?.targetRotation = rotation?.let { rotation - (map?.camera?.rotation ?: 0.0) }?.toFloat() + private fun onCompassRotationChanged(heading: HeadingMeasurement) { + val cameraBearing = Bearing.North + (map?.camera?.rotation ?: 0.0).degrees + locationMapComponent?.targetRotation = (heading.bearing - cameraBearing).inDegrees.toFloat() } fun onLocationEvent(locationEvent: LocationEvent) { when (locationEvent) { - is LocationEvent.Fix -> { - val location = locationEvent.location + is LocationEvent.Update -> { + val location = locationEvent.measurement displayedLocation = location - surveyChecker.addRecentLocation(location.toLocation()) - locationMapComponent?.targetPositionWithAccuracy = location.position + surveyChecker.addRecentLocation(locationEvent.toLocation()) + locationMapComponent?.targetLocation = location addTrackLocation(location) centerCurrentPositionIfFollowing() } is LocationEvent.Unavailable -> { - locationMapComponent?.targetPositionWithAccuracy = null + locationMapComponent?.targetLocation = null displayedLocation = null isNavigationMode = false @@ -408,24 +398,24 @@ class MainMapFragment : MapFragment() { previouslyHiddenLayers = new } - private fun addTrackLocation(location: Location) { + private fun addTrackLocation(location: LocationMeasurement) { // ignore if too imprecise - val accuracy = location.position.accuracy?.toFloat(International.Meters) + val accuracy = location.horizontalAccuracy?.toFloat(International.Meters) if (accuracy != null && accuracy > MIN_TRACK_ACCURACY) return val lastLocation = tracks.last().lastOrNull() // create new track if last position too old if (lastLocation != null && !isRecordingTracks) { - if ((displayedLocation?.timestamp?.elapsedNow()?.inWholeMilliseconds ?: 0) - lastLocation.time > MAX_TIME_BETWEEN_LOCATIONS) { + if (location.measuredAt.toEpochMilliseconds() - lastLocation.time > MAX_TIME_BETWEEN_LOCATIONS) { tracks.add(ArrayList()) tracksMapComponent?.startNewTrack(false) } } val trackpoint = Trackpoint( - position = location.position.value.toLatLon(), - time = location.timestamp.elapsedNow().inWholeMilliseconds, + position = location.position.toLatLon(), + time = location.measuredAt.toEpochMilliseconds(), accuracy = accuracy ?: 0f, - elevation = location.position.value.altitude?.toFloat() ?: 0f + elevation = location.position.altitude?.toFloat() ?: 0f ) tracks.last().add(trackpoint) @@ -512,7 +502,7 @@ class MainMapFragment : MapFragment() { } private fun centerCurrentPosition() { - val displayedPosition = displayedLocation?.position?.value?.let { it.toLatLon() } ?: return + val displayedPosition = displayedLocation?.position?.let { it.toLatLon() } ?: return updateCameraPosition(600) { if (isNavigationMode) { @@ -571,7 +561,7 @@ class MainMapFragment : MapFragment() { //endregion companion object { - private const val DISPLAYED_POSITION = "displayed_position" + private const val DISPLAYED_LOCATION = "displayed_location" private const val TRACKS = "tracks" private const val TRACKS_IS_RECORDING = "tracks_is_recording" diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/components/CurrentLocationMapComponent.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/components/CurrentLocationMapComponent.kt index 417edaf325c..e6ba0e90c9a 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/components/CurrentLocationMapComponent.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/screens/main/map/components/CurrentLocationMapComponent.kt @@ -25,7 +25,7 @@ import org.maplibre.android.style.layers.Property import org.maplibre.android.style.layers.PropertyFactory.* import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource -import org.maplibre.compose.location.PositionWithAccuracy +import org.maplibre.compose.location.LocationMeasurement import org.maplibre.geojson.Feature import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.units.International @@ -38,38 +38,40 @@ class CurrentLocationMapComponent(context: Context, mapStyle: Style, private val private val locationAnimation = ValueAnimator() /** The location the GPS location dot on the map should be animated to */ - var targetPositionWithAccuracy: PositionWithAccuracy? = null + var targetLocation: LocationMeasurement? = null @UiThread set(value) { if (field == value) return field = value - val positionWithAccuracy = this.positionWithAccuracy - if (positionWithAccuracy == null || value == null) { + val location = this.location + if (location == null || value == null) { locationAnimation.cancel() - this.positionWithAccuracy = value + this.location = value update() } else { - locationAnimation.setObjectValues(positionWithAccuracy, value) + locationAnimation.setObjectValues(location, value) locationAnimation.setEvaluator(locationTypeEvaluator) locationAnimation.start() } } /** The location of the GPS location dot on the map (animated) */ - var positionWithAccuracy: PositionWithAccuracy? = null + var location: LocationMeasurement? = null private set - private val locationTypeEvaluator = object : TypeEvaluator { - override fun evaluate(fraction: Float, s: PositionWithAccuracy, e: PositionWithAccuracy): PositionWithAccuracy { - val sp = s.value - val ep = e.value - val sa = s.accuracy - val ea = e.accuracy - return PositionWithAccuracy( - value = Position( + private val locationTypeEvaluator = object : TypeEvaluator { + override fun evaluate(fraction: Float, s: LocationMeasurement, e: LocationMeasurement): LocationMeasurement { + val sp = s.position + val ep = e.position + val sa = s.horizontalAccuracy + val ea = e.horizontalAccuracy + // Preserve measuredAt from the target measurement while animating its position and accuracy. + return e.copy( + position = Position( longitude = normalizeLongitude(sp.longitude + (ep.longitude - sp.longitude) * fraction), latitude = sp.latitude + (ep.latitude - sp.latitude) * fraction, + altitude = ep.altitude, ), - accuracy = if (ea != null && sa != null) { + horizontalAccuracy = if (ea != null && sa != null) { sa + (ea - sa) * fraction.toDouble() } else { null } ) @@ -148,7 +150,7 @@ class CurrentLocationMapComponent(context: Context, mapStyle: Style, private val locationAnimation.duration = 600L locationAnimation.interpolator = AccelerateDecelerateInterpolator() locationAnimation.addUpdateListener { - positionWithAccuracy = locationAnimation.animatedValue as PositionWithAccuracy + location = locationAnimation.animatedValue as LocationMeasurement update() } @@ -195,15 +197,15 @@ class CurrentLocationMapComponent(context: Context, mapStyle: Style, private val /** Update the GPS position shown on the map */ private fun update() { - val positionWithAccuracy = this.positionWithAccuracy - if (positionWithAccuracy == null) { + val location = this.location + if (location == null) { locationSource.clear() return } - val pos = positionWithAccuracy.value + val pos = location.position val p = JsonObject() - p.addProperty("radius", positionWithAccuracy.accuracy?.toDouble(International.Meters)) + p.addProperty("radius", location.horizontalAccuracy?.toDouble(International.Meters)) rotation?.let { p.addProperty("rotation", it) } map.style?.getLayerAs("accuracy")?.setProperties( circleRadius(inMeters(get("radius"), pos.latitude)) diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ui/util/Location.android.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ui/util/Location.android.kt index f59a1ab5bb8..93531f68f38 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ui/util/Location.android.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/ui/util/Location.android.kt @@ -1,15 +1,16 @@ package de.westnordost.streetcomplete.ui.util import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import org.maplibre.compose.location.AndroidLocationProvider import org.maplibre.compose.location.LocationProvider import org.maplibre.compose.location.createDefaultLocationProvider @Composable public actual fun rememberDefaultLocationProvider(): LocationProvider { val context = LocalContext.current - AndroidLocationProvider(context) - return remember(context) { createDefaultLocationProvider(context) } + val provider = remember(context) { createDefaultLocationProvider(context) } + DisposableEffect(provider) { onDispose { provider.close() } } + return provider } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/location/RecentLocations.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/location/RecentLocations.kt index 9d731096114..981f855fcdd 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/location/RecentLocations.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/location/RecentLocations.kt @@ -34,8 +34,8 @@ class RecentLocations( fun add(location: Location): Unit = lock.withLock { // only add newer locations - val firstDuration = locations.firstOrNull()?.elapsedDuration ?: Duration.ZERO - if (firstDuration >= location.elapsedDuration) return@withLock + val firstDuration = locations.firstOrNull()?.elapsedDuration + if (firstDuration != null && firstDuration >= location.elapsedDuration) return@withLock // clear from deque all older than `maxAge` before inserting new while ( diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/AutoSyncer.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/AutoSyncer.kt index 436d814a29a..25fe9e6b834 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/AutoSyncer.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/AutoSyncer.kt @@ -118,8 +118,8 @@ class AutoSyncer( owner.repeatOnLifecycle(Lifecycle.State.STARTED) { val request = LocationRequest(LocationAccuracy.High, 30.seconds, 100.meters) locationProvider.updates(request).collect { locationEvent -> - if (locationEvent is LocationEvent.Fix) { - val (position, accuracy) = locationEvent.location.position + if (locationEvent is LocationEvent.Update) { + val (position, accuracy) = locationEvent.measurement if (accuracy == null || accuracy < 300.meters) { pos = LatLon(position.latitude, position.longitude) triggerAutoDownload() diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt index a6ee3873989..ab0cefd9909 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt @@ -2,13 +2,17 @@ package de.westnordost.streetcomplete.util.ktx import de.westnordost.streetcomplete.data.location.Location import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import org.maplibre.compose.location.LocationEvent import org.maplibre.spatialk.units.International +import kotlin.time.TimeSource -fun org.maplibre.compose.location.Location.toLocation(): Location = +private val locationTimeMark = TimeSource.Monotonic.markNow() + +fun LocationEvent.Update.toLocation(): Location = Location( - position = position.value.toLatLon(), - accuracy = position.accuracy?.toFloat(International.Meters) ?: 0f, - elapsedDuration = timestamp.elapsedNow(), + position = measurement.position.toLatLon(), + accuracy = measurement.horizontalAccuracy?.toFloat(International.Meters) ?: 0f, + elapsedDuration = locationTimeMark.elapsedNow() - measurementMark.elapsedNow(), ) fun org.maplibre.spatialk.geojson.Position.toLatLon(): LatLon = diff --git a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/location/RecentLocationsTest.kt b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/location/RecentLocationsTest.kt index 6a6acdef84f..ee572c84c6b 100644 --- a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/location/RecentLocationsTest.kt +++ b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/location/RecentLocationsTest.kt @@ -25,6 +25,23 @@ class RecentLocationsTest { ) } + @Test fun `accepts first location with zero elapsedDuration`() { + val r = RecentLocations(10.seconds, 1.0, 1.seconds) + val location = Location(LatLon(0.0, 0.0), 1f, 0.seconds) + r.add(location) + + assertEquals(location, r.getAll().toList().single()) + } + + @Test fun `accepts first location with negative elapsedDuration`() { + // A cached fix can predate the adapter's monotonic time origin. + val r = RecentLocations(10.seconds, 1.0, 1.seconds) + val location = Location(LatLon(0.0, 0.0), 1f, (-1).seconds) + r.add(location) + + assertEquals(location, r.getAll().toList().single()) + } + @Test fun `getAll returns ordered by elapsedDuration descending`() { val r = RecentLocations(10.seconds, 1.0, 1.seconds) val l1 = Location(LatLon(0.0, 0.0), 1f, 1.seconds) diff --git a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt new file mode 100644 index 00000000000..68b3a40b827 --- /dev/null +++ b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt @@ -0,0 +1,32 @@ +package de.westnordost.streetcomplete.util.ktx + +import de.westnordost.streetcomplete.data.location.RecentLocations +import org.maplibre.compose.location.LocationEvent +import org.maplibre.compose.location.LocationMeasurement +import org.maplibre.spatialk.geojson.Position +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlin.time.TimeSource + +class LocationTest { + @Test fun `survey locations follow monotonic measurement order`() { + // Using a fix's age as its timestamp made RecentLocations treat older fixes as newer. + val mark = TimeSource.Monotonic.markNow() + val measurement = LocationMeasurement( + position = Position(2.0, 1.0), + measuredAt = Instant.fromEpochSeconds(100), + ) + val older = LocationEvent.Update(measurement, mark - 10.seconds).toLocation() + val newer = LocationEvent.Update( + measurement.copy(position = Position(3.0, 1.0), measuredAt = Instant.fromEpochSeconds(90)), + mark, + ).toLocation() + + val recent = RecentLocations(60.seconds, 1.0, 1.seconds) + recent.add(older) + recent.add(newer) + assertEquals(listOf(newer, older), recent.getAll().toList()) + } +} diff --git a/app/src/iosMain/kotlin/de/westnordost/streetcomplete/ui/util/Location.ios.kt b/app/src/iosMain/kotlin/de/westnordost/streetcomplete/ui/util/Location.ios.kt index b4bac024208..c26c1f6e622 100644 --- a/app/src/iosMain/kotlin/de/westnordost/streetcomplete/ui/util/Location.ios.kt +++ b/app/src/iosMain/kotlin/de/westnordost/streetcomplete/ui/util/Location.ios.kt @@ -1,10 +1,14 @@ package de.westnordost.streetcomplete.ui.util import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import org.maplibre.compose.location.IosLocationProvider import org.maplibre.compose.location.LocationProvider -@Composable actual fun rememberDefaultLocationProvider(): LocationProvider = remember { - IosLocationProvider() +// Identical to the upstream maplibre-compose implementation. +@Composable actual fun rememberDefaultLocationProvider(): LocationProvider { + val provider = remember { IosLocationProvider() } + DisposableEffect(provider) { onDispose { provider.close() } } + return provider } From 90ea458657d6ef08b21473530219697cc0671947 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Thu, 10 Sep 2026 00:36:24 -0700 Subject: [PATCH 2/5] Restart location updates on permission changes and scope provider to activity --- .../screens/main/MainActivity.kt | 3 +- .../streetcomplete/AndroidModule.kt | 8 +- .../streetcomplete/CommonModule.kt | 3 - .../streetcomplete/data/quest/AutoSyncer.kt | 3 +- .../streetcomplete/util/ktx/Location.kt | 9 +++ .../util/ktx/LocationProviderTest.kt | 81 +++++++++++++++++++ 6 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationProviderTest.kt diff --git a/androidApp/src/main/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt b/androidApp/src/main/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt index bd5547f7f11..56fea39732c 100644 --- a/androidApp/src/main/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt +++ b/androidApp/src/main/kotlin/de/westnordost/streetcomplete/screens/main/MainActivity.kt @@ -78,6 +78,7 @@ import de.westnordost.streetcomplete.util.ktx.observe import de.westnordost.streetcomplete.util.ktx.toLatLon import de.westnordost.streetcomplete.util.ktx.toOffset import de.westnordost.streetcomplete.util.ktx.toast +import de.westnordost.streetcomplete.util.ktx.updatesWithPermissionChanges import de.westnordost.streetcomplete.util.math.area import de.westnordost.streetcomplete.util.math.enclosingBoundingBox import de.westnordost.streetcomplete.util.math.enlargedBy @@ -348,7 +349,7 @@ class MainActivity : mainBottomSheetViewModel.closeBottomSheet() } } - observe(locationProvider.updates(LocationRequest())) { locationEvent -> + observe(locationProvider.updatesWithPermissionChanges(LocationRequest())) { locationEvent -> viewModel.locationState.value = when (locationEvent) { is LocationEvent.Update -> LocationState.UPDATING is LocationEvent.Unavailable -> when (locationEvent.reason) { diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt index 5e63d514dc9..2fe060a7676 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt @@ -1,5 +1,6 @@ package de.westnordost.streetcomplete +import android.app.Activity import android.content.Context import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.russhwolf.settings.ObservableSettings @@ -23,6 +24,7 @@ import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloaderAndroid import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.AndroidChangesetAutoCloser import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetAutoCloser import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetAutoCloserWorker +import de.westnordost.streetcomplete.data.quest.AutoSyncer import de.westnordost.streetcomplete.data.upload.AndroidUploadController import de.westnordost.streetcomplete.data.upload.UploadController import de.westnordost.streetcomplete.data.upload.UploadWorker @@ -42,6 +44,7 @@ import kotlinx.io.asSource import kotlinx.io.buffered import kotlinx.io.files.Path import org.koin.android.ext.koin.androidContext +import org.koin.androidx.scope.dsl.activityScope import org.koin.androidx.workmanager.dsl.worker import org.koin.core.qualifier.named import org.koin.dsl.module @@ -99,7 +102,10 @@ val androidModule = module { // location - factory { AndroidLocationProvider(get()) } + activityScope { + scoped { AndroidLocationProvider(get()) } onClose { it?.close() } + scoped { AutoSyncer(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + } factory { AndroidSystemSettingsLauncher(get()) } // settings diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/CommonModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/CommonModule.kt index b7727e97985..70e9503cc73 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/CommonModule.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/CommonModule.kt @@ -105,7 +105,6 @@ import de.westnordost.streetcomplete.data.preferences.ResurveyIntervalsUpdater import de.westnordost.streetcomplete.data.presets.EditTypePresetsController import de.westnordost.streetcomplete.data.presets.EditTypePresetsDao import de.westnordost.streetcomplete.data.presets.EditTypePresetsSource -import de.westnordost.streetcomplete.data.quest.AutoSyncer import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource import de.westnordost.streetcomplete.data.upload.UploadProgressSource @@ -251,8 +250,6 @@ val commonModule = module { //region upload & download - single { AutoSyncer(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } - // upload single { UnsyncedChangesCountSource(get(), get()) } diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/AutoSyncer.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/AutoSyncer.kt index 25fe9e6b834..bba0ae2bc6e 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/AutoSyncer.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/data/quest/AutoSyncer.kt @@ -19,6 +19,7 @@ import de.westnordost.streetcomplete.data.upload.UploadController import de.westnordost.streetcomplete.data.user.UserLoginSource import de.westnordost.streetcomplete.data.visiblequests.TeamModeQuestFilterSource import de.westnordost.streetcomplete.util.ktx.format +import de.westnordost.streetcomplete.util.ktx.updatesWithPermissionChanges import de.westnordost.streetcomplete.util.logs.Log import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -117,7 +118,7 @@ class AutoSyncer( coroutineScope.launch { owner.repeatOnLifecycle(Lifecycle.State.STARTED) { val request = LocationRequest(LocationAccuracy.High, 30.seconds, 100.meters) - locationProvider.updates(request).collect { locationEvent -> + locationProvider.updatesWithPermissionChanges(request).collect { locationEvent -> if (locationEvent is LocationEvent.Update) { val (position, accuracy) = locationEvent.measurement if (accuracy == null || accuracy < 300.meters) { diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt index ab0cefd9909..f1ca2689597 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt @@ -2,7 +2,12 @@ package de.westnordost.streetcomplete.util.ktx import de.westnordost.streetcomplete.data.location.Location import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import org.maplibre.compose.location.LocationEvent +import org.maplibre.compose.location.LocationProvider +import org.maplibre.compose.location.LocationRequest import org.maplibre.spatialk.units.International import kotlin.time.TimeSource @@ -17,3 +22,7 @@ fun LocationEvent.Update.toLocation(): Location = fun org.maplibre.spatialk.geojson.Position.toLatLon(): LatLon = LatLon(latitude, longitude) + +@OptIn(ExperimentalCoroutinesApi::class) +fun LocationProvider.updatesWithPermissionChanges(request: LocationRequest): Flow = + permission.flatMapLatest { updates(request) } diff --git a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationProviderTest.kt b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationProviderTest.kt new file mode 100644 index 00000000000..73a5a69244c --- /dev/null +++ b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationProviderTest.kt @@ -0,0 +1,81 @@ +package de.westnordost.streetcomplete.util.ktx + +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.maplibre.compose.location.LocationAccuracyAuthorization +import org.maplibre.compose.location.LocationEvent +import org.maplibre.compose.location.LocationMeasurement +import org.maplibre.compose.location.LocationPermission +import org.maplibre.compose.location.LocationProvider +import org.maplibre.compose.location.LocationRequest +import org.maplibre.compose.location.LocationUnavailableReason +import org.maplibre.spatialk.geojson.Position +import org.maplibre.spatialk.units.extensions.meters +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlin.time.TimeSource + +class LocationProviderTest { + @Test fun `permission changes restart updates without requesting permission`() = runBlocking { + withTimeout(5.seconds) { + val denied = LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied) + val update = LocationEvent.Update( + LocationMeasurement(Position(2.0, 1.0), measuredAt = Instant.fromEpochSeconds(100)), + TimeSource.Monotonic.markNow(), + ) + val requests = mutableListOf() + var activeRequests = 0 + val provider = object : LocationProvider { + override val permission = MutableStateFlow(LocationPermission.NotGranted(true)) + + override fun requestPermission() = error("Permission prompts must remain explicit") + + override fun updates(request: LocationRequest) = flow { + requests.add(request) + if (permission.value !is LocationPermission.Granted) { + emit(denied) + return@flow + } + activeRequests++ + try { + emit(update) + awaitCancellation() + } finally { + activeRequests-- + } + } + } + val events = Channel(Channel.UNLIMITED) + val request = LocationRequest(minimumInterval = 30.seconds, minimumDistance = 100.meters) + val job = launch { + provider.updatesWithPermissionChanges(request).collect { events.send(it) } + } + try { + assertEquals(denied, events.receive()) + provider.permission.value = LocationPermission.Granted(LocationAccuracyAuthorization.Precise) + assertEquals(update, events.receive()) + assertEquals(1, activeRequests) + + provider.permission.value = LocationPermission.NotGranted(true) + assertEquals(denied, events.receive()) + assertEquals(0, activeRequests) + + provider.permission.value = LocationPermission.Granted(LocationAccuracyAuthorization.Precise) + assertEquals(update, events.receive()) + assertEquals(1, activeRequests) + assertEquals(List(4) { request }, requests) + } finally { + job.cancel() + job.join() + } + assertEquals(0, activeRequests) + } + } +} From aa30f181f14d8a81ffefc16cf0a3bb4ee6453c44 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Thu, 10 Sep 2026 12:10:21 -0700 Subject: [PATCH 3/5] Keep auto sync ownership common and simplify location tests --- .../streetcomplete/AndroidModule.kt | 8 +- .../streetcomplete/CommonModule.kt | 3 + .../util/ktx/LocationProviderTest.kt | 81 ------------------- .../streetcomplete/util/ktx/LocationTest.kt | 10 +-- 4 files changed, 7 insertions(+), 95 deletions(-) delete mode 100644 app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationProviderTest.kt diff --git a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt index 2fe060a7676..5e63d514dc9 100644 --- a/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt +++ b/app/src/androidMain/kotlin/de/westnordost/streetcomplete/AndroidModule.kt @@ -1,6 +1,5 @@ package de.westnordost.streetcomplete -import android.app.Activity import android.content.Context import androidx.sqlite.driver.bundled.BundledSQLiteDriver import com.russhwolf.settings.ObservableSettings @@ -24,7 +23,6 @@ import de.westnordost.streetcomplete.data.maptiles.MapTilesDownloaderAndroid import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.AndroidChangesetAutoCloser import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetAutoCloser import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetAutoCloserWorker -import de.westnordost.streetcomplete.data.quest.AutoSyncer import de.westnordost.streetcomplete.data.upload.AndroidUploadController import de.westnordost.streetcomplete.data.upload.UploadController import de.westnordost.streetcomplete.data.upload.UploadWorker @@ -44,7 +42,6 @@ import kotlinx.io.asSource import kotlinx.io.buffered import kotlinx.io.files.Path import org.koin.android.ext.koin.androidContext -import org.koin.androidx.scope.dsl.activityScope import org.koin.androidx.workmanager.dsl.worker import org.koin.core.qualifier.named import org.koin.dsl.module @@ -102,10 +99,7 @@ val androidModule = module { // location - activityScope { - scoped { AndroidLocationProvider(get()) } onClose { it?.close() } - scoped { AutoSyncer(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } - } + factory { AndroidLocationProvider(get()) } factory { AndroidSystemSettingsLauncher(get()) } // settings diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/CommonModule.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/CommonModule.kt index 70e9503cc73..b7727e97985 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/CommonModule.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/CommonModule.kt @@ -105,6 +105,7 @@ import de.westnordost.streetcomplete.data.preferences.ResurveyIntervalsUpdater import de.westnordost.streetcomplete.data.presets.EditTypePresetsController import de.westnordost.streetcomplete.data.presets.EditTypePresetsDao import de.westnordost.streetcomplete.data.presets.EditTypePresetsSource +import de.westnordost.streetcomplete.data.quest.AutoSyncer import de.westnordost.streetcomplete.data.quest.QuestTypeRegistry import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource import de.westnordost.streetcomplete.data.upload.UploadProgressSource @@ -250,6 +251,8 @@ val commonModule = module { //region upload & download + single { AutoSyncer(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + // upload single { UnsyncedChangesCountSource(get(), get()) } diff --git a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationProviderTest.kt b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationProviderTest.kt deleted file mode 100644 index 73a5a69244c..00000000000 --- a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationProviderTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package de.westnordost.streetcomplete.util.ktx - -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import org.maplibre.compose.location.LocationAccuracyAuthorization -import org.maplibre.compose.location.LocationEvent -import org.maplibre.compose.location.LocationMeasurement -import org.maplibre.compose.location.LocationPermission -import org.maplibre.compose.location.LocationProvider -import org.maplibre.compose.location.LocationRequest -import org.maplibre.compose.location.LocationUnavailableReason -import org.maplibre.spatialk.geojson.Position -import org.maplibre.spatialk.units.extensions.meters -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant -import kotlin.time.TimeSource - -class LocationProviderTest { - @Test fun `permission changes restart updates without requesting permission`() = runBlocking { - withTimeout(5.seconds) { - val denied = LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied) - val update = LocationEvent.Update( - LocationMeasurement(Position(2.0, 1.0), measuredAt = Instant.fromEpochSeconds(100)), - TimeSource.Monotonic.markNow(), - ) - val requests = mutableListOf() - var activeRequests = 0 - val provider = object : LocationProvider { - override val permission = MutableStateFlow(LocationPermission.NotGranted(true)) - - override fun requestPermission() = error("Permission prompts must remain explicit") - - override fun updates(request: LocationRequest) = flow { - requests.add(request) - if (permission.value !is LocationPermission.Granted) { - emit(denied) - return@flow - } - activeRequests++ - try { - emit(update) - awaitCancellation() - } finally { - activeRequests-- - } - } - } - val events = Channel(Channel.UNLIMITED) - val request = LocationRequest(minimumInterval = 30.seconds, minimumDistance = 100.meters) - val job = launch { - provider.updatesWithPermissionChanges(request).collect { events.send(it) } - } - try { - assertEquals(denied, events.receive()) - provider.permission.value = LocationPermission.Granted(LocationAccuracyAuthorization.Precise) - assertEquals(update, events.receive()) - assertEquals(1, activeRequests) - - provider.permission.value = LocationPermission.NotGranted(true) - assertEquals(denied, events.receive()) - assertEquals(0, activeRequests) - - provider.permission.value = LocationPermission.Granted(LocationAccuracyAuthorization.Precise) - assertEquals(update, events.receive()) - assertEquals(1, activeRequests) - assertEquals(List(4) { request }, requests) - } finally { - job.cancel() - job.join() - } - assertEquals(0, activeRequests) - } - } -} diff --git a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt index 68b3a40b827..c7efa5c153c 100644 --- a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt +++ b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt @@ -1,17 +1,16 @@ package de.westnordost.streetcomplete.util.ktx -import de.westnordost.streetcomplete.data.location.RecentLocations import org.maplibre.compose.location.LocationEvent import org.maplibre.compose.location.LocationMeasurement import org.maplibre.spatialk.geojson.Position import kotlin.test.Test -import kotlin.test.assertEquals +import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant import kotlin.time.TimeSource class LocationTest { - @Test fun `survey locations follow monotonic measurement order`() { + @Test fun `conversion orders fixes by monotonic measurement time`() { // Using a fix's age as its timestamp made RecentLocations treat older fixes as newer. val mark = TimeSource.Monotonic.markNow() val measurement = LocationMeasurement( @@ -24,9 +23,6 @@ class LocationTest { mark, ).toLocation() - val recent = RecentLocations(60.seconds, 1.0, 1.seconds) - recent.add(older) - recent.add(newer) - assertEquals(listOf(newer, older), recent.getAll().toList()) + assertTrue(newer.elapsedDuration > older.elapsedDuration) } } From 61519455eaea904b571e4ae8edff4e7c20f9ff47 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Thu, 10 Sep 2026 19:35:03 -0700 Subject: [PATCH 4/5] Track upstream permission recovery fix --- .../kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt index f1ca2689597..1819e93b595 100644 --- a/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt +++ b/app/src/commonMain/kotlin/de/westnordost/streetcomplete/util/ktx/Location.kt @@ -23,6 +23,7 @@ fun LocationEvent.Update.toLocation(): Location = fun org.maplibre.spatialk.geojson.Position.toLatLon(): LatLon = LatLon(latitude, longitude) +// TODO remove after upgrading to a version containing https://github.com/maplibre/maplibre-compose/pull/1393 @OptIn(ExperimentalCoroutinesApi::class) fun LocationProvider.updatesWithPermissionChanges(request: LocationRequest): Flow = permission.flatMapLatest { updates(request) } From e582178d6c4efe258612e47bcba715f4e822d7c4 Mon Sep 17 00:00:00 2001 From: Sargun Vohra Date: Thu, 10 Sep 2026 19:39:35 -0700 Subject: [PATCH 5/5] Test converted location ordering in RecentLocationsTest --- .../data/location/RecentLocationsTest.kt | 25 +++++++++++++++++ .../streetcomplete/util/ktx/LocationTest.kt | 28 ------------------- 2 files changed, 25 insertions(+), 28 deletions(-) delete mode 100644 app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt diff --git a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/location/RecentLocationsTest.kt b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/location/RecentLocationsTest.kt index ee572c84c6b..798a4120819 100644 --- a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/location/RecentLocationsTest.kt +++ b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/data/location/RecentLocationsTest.kt @@ -2,11 +2,17 @@ package de.westnordost.streetcomplete.data.location import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.util.ktx.asSequenceOfPairs +import de.westnordost.streetcomplete.util.ktx.toLocation import de.westnordost.streetcomplete.util.math.translate +import org.maplibre.compose.location.LocationEvent +import org.maplibre.compose.location.LocationMeasurement +import org.maplibre.spatialk.geojson.Position import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import kotlin.time.TimeSource class RecentLocationsTest { @Test fun `getAll returns nothing when empty`() { @@ -54,6 +60,25 @@ class RecentLocationsTest { } } + @Test fun `retains a fresh fix after an older converted fix`() { + // Using a fix's age as its timestamp made RecentLocations discard newer fixes. + val r = RecentLocations(60.seconds, 1.0, 1.seconds) + val mark = TimeSource.Monotonic.markNow() + val measurement = LocationMeasurement( + position = Position(2.0, 1.0), + measuredAt = Instant.fromEpochSeconds(100), + ) + val older = LocationEvent.Update(measurement, mark - 10.seconds).toLocation() + val newer = LocationEvent.Update( + measurement.copy(position = Position(3.0, 1.0), measuredAt = Instant.fromEpochSeconds(110)), + mark, + ).toLocation() + r.add(older) + r.add(newer) + + assertEquals(listOf(newer, older), r.getAll().toList()) + } + @Test fun `does not add older locations`() { val r = RecentLocations(10.seconds, 1.0, 1.seconds) val l1 = Location(LatLon(0.0, 0.0), 1f, 10.seconds) diff --git a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt b/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt deleted file mode 100644 index c7efa5c153c..00000000000 --- a/app/src/commonTest/kotlin/de/westnordost/streetcomplete/util/ktx/LocationTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package de.westnordost.streetcomplete.util.ktx - -import org.maplibre.compose.location.LocationEvent -import org.maplibre.compose.location.LocationMeasurement -import org.maplibre.spatialk.geojson.Position -import kotlin.test.Test -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds -import kotlin.time.Instant -import kotlin.time.TimeSource - -class LocationTest { - @Test fun `conversion orders fixes by monotonic measurement time`() { - // Using a fix's age as its timestamp made RecentLocations treat older fixes as newer. - val mark = TimeSource.Monotonic.markNow() - val measurement = LocationMeasurement( - position = Position(2.0, 1.0), - measuredAt = Instant.fromEpochSeconds(100), - ) - val older = LocationEvent.Update(measurement, mark - 10.seconds).toLocation() - val newer = LocationEvent.Update( - measurement.copy(position = Position(3.0, 1.0), measuredAt = Instant.fromEpochSeconds(90)), - mark, - ).toLocation() - - assertTrue(newer.elapsedDuration > older.elapsedDuration) - } -}