diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b0fd9f0657..c1e1d4d9f0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -111,10 +111,13 @@ dependencies {
testImplementation("org.mockito:mockito-core:$mockitoVersion")
testImplementation("org.mockito:mockito-inline:$mockitoVersion")
testImplementation(kotlin("test"))
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")
androidTestImplementation("androidx.test:runner:1.6.1")
androidTestImplementation("androidx.test:rules:1.6.1")
androidTestImplementation("org.mockito:mockito-android:$mockitoVersion")
+ androidTestImplementation("org.assertj:assertj-core:3.23.1")
+ androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")
androidTestImplementation(kotlin("test"))
// dependency injection
diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/data/import/GpxImportParseTest.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/data/import/GpxImportParseTest.kt
new file mode 100644
index 0000000000..36875a3611
--- /dev/null
+++ b/app/src/androidTest/java/de/westnordost/streetcomplete/data/import/GpxImportParseTest.kt
@@ -0,0 +1,214 @@
+package de.westnordost.streetcomplete.data.import
+
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.runBlocking
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFails
+
+class GpxImportParseTest {
+ @Test
+ fun successfullyParsesMinimalSampleTrack() = runBlocking {
+ val originalTrackPoints = arrayListOf(
+ TrackPoint("22.22", "172.3"),
+ TrackPoint("39.11111", "-179.999"),
+ TrackPoint("-25.312", "7"),
+ TrackPoint("57.0", "123"),
+ TrackPoint("-89.9999", "-12.02"),
+ TrackPoint("-72.0", "0.3"),
+ )
+
+ val inputGpx =
+ minimalGpxBuilder(originalTrackPoints)
+
+ assertSuccess(
+ originalTrackPoints,
+ parseGpx(inputGpx)
+ )
+ }
+
+ @Test
+ fun concatenatesMultipleTrackSegments() = runBlocking {
+ val trackPointsSegment1 = arrayListOf(
+ TrackPoint("-56.0", "0.0"),
+ TrackPoint("57.57", "172.3")
+ )
+ val trackPointsSegment2 = arrayListOf(
+ TrackPoint("-87.0", "-99.2"),
+ TrackPoint("12.67", "132.29")
+ )
+
+ val inputGpx = buildString {
+ append("")
+ append("")
+ append("")
+ trackPointsSegment1.forEach {
+ append("")
+ }
+ append("")
+ append("")
+ trackPointsSegment2.forEach {
+ append("")
+ }
+ append("")
+ append("")
+ append("")
+ }
+
+ assertSuccess(
+ trackPointsSegment1 + trackPointsSegment2,
+ parseGpx(inputGpx)
+ )
+ }
+
+ @Test
+ fun processesMultipleTracksAndSegments() = runBlocking {
+ val trackPoints1 = arrayListOf(
+ TrackPoint("-12.33", "0.0"),
+ TrackPoint("74.1", "-122.34")
+ )
+ val trackPoints2 = arrayListOf(
+ TrackPoint("-0.0", "-12"),
+ TrackPoint("-90.0", "180.0")
+ )
+ val trackPoints3 = arrayListOf(
+ TrackPoint("11.1", "-92"),
+ TrackPoint("90", "-0.0")
+ )
+
+ val inputGpx = buildString {
+ append("")
+ append("")
+ append("")
+ trackPoints1.forEach {
+ append("")
+ }
+ append("")
+ append("")
+ trackPoints2.forEach {
+ append("")
+ }
+ append("")
+ append("")
+ append("")
+ append("")
+ trackPoints3.forEach {
+ append("")
+ }
+ append("")
+ append("")
+ append("")
+ }
+
+ assertSuccess(
+ trackPoints1 + trackPoints2 + trackPoints3,
+ parseGpx(inputGpx)
+ )
+ }
+
+ @Test
+ fun throwsOnInvalidTrackPoints(): Unit = runBlocking {
+ assertFails {
+ parseGpx(
+ minimalGpxBuilder(
+ listOf(TrackPoint("99.0", "-12.1"))
+ )
+ )
+ }
+ assertFails {
+ parseGpx(
+ minimalGpxBuilder(
+ listOf(TrackPoint("-11.5", "-181.0"))
+ )
+ )
+ }
+ }
+
+ @Test
+ fun throwsOnNonGpxFiles(): Unit = runBlocking {
+ val nonGpxXml = """
+
+
+ """.trimIndent()
+ assertFails {
+ parseGpx(nonGpxXml)
+ }
+ }
+
+ @Test
+ fun exhaustingOuterBeforeInnerFlowYieldsNoElements() = runBlocking {
+ val inputGpx = minimalGpxBuilder(
+ arrayListOf(
+ TrackPoint("-39.654", "180"),
+ TrackPoint("90.0", "-180")
+ )
+ )
+
+ // exhausting outer first
+ val incorrectlyRetrievedSegments = parseGpxFile(inputGpx.byteInputStream()).toList()
+ assertEquals(
+ 1, incorrectlyRetrievedSegments.size,
+ "Exhausting outer first fails to retrieve the track segment"
+ )
+ assertEquals(
+ emptyList(), incorrectlyRetrievedSegments.first().toList(),
+ "Exhausting outer first unexpectedly yields track points"
+ )
+
+ // exhausting inner first
+ val correctlyRetrievedSegments = parseGpx(inputGpx)
+ assertEquals(
+ 2, correctlyRetrievedSegments.size,
+ "Exhausting inner first fails to retrieve track points"
+ )
+ }
+
+ @Test
+ fun handlesAdditionalDataGracefully() = runBlocking {
+ val originalTrackPoints =
+ arrayListOf(TrackPoint("88", "-19"))
+
+ val inputGpx = buildString {
+ append("")
+ append("")
+ append("Some GPS track")
+ append("")
+ append("")
+ append("")
+ originalTrackPoints.forEach {
+ append("")
+ }
+ append("")
+ append("")
+ append("")
+ }
+
+ assertSuccess(
+ originalTrackPoints,
+ parseGpx(inputGpx)
+ )
+ }
+
+ private fun assertSuccess(
+ originalTrackPoints: List,
+ parseResult: List,
+ ) {
+ assertEquals(
+ originalTrackPoints.size, parseResult.size,
+ "Not all trackPoints are retrieved"
+ )
+ originalTrackPoints.map { it.toLatLon() }.zip(parseResult).forEach { pair ->
+ assertEquals(
+ expected = pair.component1().latitude,
+ actual = pair.component2().latitude,
+ "Latitudes don't match"
+ )
+ assertEquals(
+ expected = pair.component1().longitude,
+ actual = pair.component2().longitude,
+ "Longitudes don't match"
+ )
+ }
+ }
+}
diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/data/import/GpxImportTest.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/data/import/GpxImportTest.kt
new file mode 100644
index 0000000000..77ec16b7e0
--- /dev/null
+++ b/app/src/androidTest/java/de/westnordost/streetcomplete/data/import/GpxImportTest.kt
@@ -0,0 +1,352 @@
+package de.westnordost.streetcomplete.data.import
+
+import de.westnordost.streetcomplete.ApplicationConstants
+import de.westnordost.streetcomplete.data.download.tiles.enclosingTilePos
+import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.util.math.contains
+import de.westnordost.streetcomplete.util.math.translate
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.runBlocking
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class GpxImportTest {
+ @Test
+ fun downloadWorksOnSingleSegmentTrack() = runBlocking {
+ val originalTrackPoints = arrayListOf(
+ TrackPoint("0.0", "0.0"),
+ TrackPoint("1.3", "-0.3"),
+ TrackPoint("2", "-2"),
+ TrackPoint("2.4", "-2.2"),
+ TrackPoint("2.4", "-2.2"),
+ TrackPoint("2.6", "-3"),
+ )
+ val inputGpx = minimalGpxBuilder(originalTrackPoints)
+ val minDownloadDistance = 100.0
+ val (segments, downloadBBoxes) = import(minDownloadDistance, inputGpx)
+ assertInvariants(
+ originalTrackPoints,
+ segments,
+ downloadBBoxes,
+ minDownloadDistance
+ )
+ }
+
+ @Test
+ fun displayOnlyImportWorksOnSingleSegmentTrack() = runBlocking {
+ val originalTrackPoints = arrayListOf(
+ TrackPoint("-36.1", "-143.0"),
+ TrackPoint("-40.2", "-179.999"),
+ TrackPoint("-42.0", "179"),
+ TrackPoint("-38.38", "171"),
+ )
+
+ val inputGpx = minimalGpxBuilder(originalTrackPoints)
+ val (segments, _) = import(250.0, inputGpx)
+
+ originalTrackPoints.forEach { trackPoint ->
+ assertTrue(
+ segments.any { it.contains(trackPoint.toLatLon()) },
+ "originalTrackPoint $trackPoint not contained in displayed segments"
+ )
+ }
+ }
+
+ @Test
+ fun eastWestLineDownloadsNecessaryArea() = runBlocking {
+ // at zoom level 16, one tile is 0.005° latitude high, or ~500m high on equator
+ // the equator itself lies directly on a boundary between two tile rows
+ // LatLon(0.0025, ..) is in the middle of the row above the equator
+ require(ApplicationConstants.DOWNLOAD_TILE_ZOOM == 16)
+ val originalTrackPoints = arrayListOf(
+ TrackPoint("0.0025", "-70.0"),
+ TrackPoint("0.0025", "-71.0"),
+ )
+ val inputGpx = minimalGpxBuilder(originalTrackPoints)
+ val centerRowY =
+ originalTrackPoints[0].toLatLon()
+ .enclosingTilePos(ApplicationConstants.DOWNLOAD_TILE_ZOOM).y
+
+ // setting the minDownloadDistance well below half tile height should download only one row
+ val smallMinDownloadDistance = 100.0
+ val (smallSegments, smallDownloadBBoxes) = import(smallMinDownloadDistance, inputGpx)
+ assertInvariants(
+ originalTrackPoints,
+ smallSegments,
+ smallDownloadBBoxes,
+ smallMinDownloadDistance
+ )
+ smallDownloadBBoxes
+ .map { it.enclosingTilesRect(ApplicationConstants.DOWNLOAD_TILE_ZOOM) }
+ .forEach {
+ assertEquals(
+ centerRowY,
+ it.top,
+ "$it scheduled for download does not start on center row"
+ )
+ assertEquals(
+ centerRowY,
+ it.bottom,
+ "$it scheduled for download does not end on center row"
+ )
+ }
+
+ // setting the minDownloadDistance at half tile width should download three rows of tiles
+ val bigMinDownloadDistance = 250.0
+ val (bigSegments, bigDownloadBBoxes) = import(bigMinDownloadDistance, inputGpx)
+ assertInvariants(
+ originalTrackPoints,
+ bigSegments,
+ bigDownloadBBoxes,
+ bigMinDownloadDistance
+ )
+ bigDownloadBBoxes
+ .map { it.enclosingTilesRect(ApplicationConstants.DOWNLOAD_TILE_ZOOM) }
+ .forEach {
+ assertEquals(
+ centerRowY - 1,
+ it.top,
+ "$it scheduled for download does not start one below center row"
+ )
+ assertEquals(
+ centerRowY + 1,
+ it.bottom,
+ "$it scheduled for download does not start one above center row"
+ )
+ }
+ }
+
+ @Test
+ fun northSouthLineDownloadsNecessaryArea() = runBlocking {
+ // at zoom level 16, one tile is 0.005° longitude wide, or ~250m at 60° latitude
+ // LatLon(.., 0.0025) is in the middle of the column to the east of the 0 meridian
+ require(ApplicationConstants.DOWNLOAD_TILE_ZOOM == 16)
+ val originalTrackPoints = arrayListOf(
+ TrackPoint("59.9", "0.0025"),
+ TrackPoint("60.1", "0.0025"),
+ )
+ val inputGpx = minimalGpxBuilder(originalTrackPoints)
+ val centerTileX =
+ originalTrackPoints[0].toLatLon()
+ .enclosingTilePos(ApplicationConstants.DOWNLOAD_TILE_ZOOM).x
+
+ // setting the minDownloadDistance well below half tile width should download only one column
+ val smallMinDownloadDistance = 50.0
+ val (smallSegments, smallDownloadBBoxes) = import(smallMinDownloadDistance, inputGpx)
+ assertInvariants(
+ originalTrackPoints,
+ smallSegments,
+ smallDownloadBBoxes,
+ smallMinDownloadDistance
+ )
+ smallDownloadBBoxes
+ .map { it.enclosingTilesRect(ApplicationConstants.DOWNLOAD_TILE_ZOOM) }
+ .forEach {
+ assertEquals(
+ centerTileX,
+ it.left,
+ "$it scheduled for download does not start on center column"
+ )
+ assertEquals(
+ centerTileX,
+ it.right,
+ "$it scheduled for download does not end on center column"
+ )
+ }
+
+ // setting the minDownloadDistance at 1.5 times tile width should download five columns
+ val bigMinDownloadDistance = 375.0
+ val (bigSegments, bigDownloadBBoxes) = import(bigMinDownloadDistance, inputGpx)
+ assertInvariants(
+ originalTrackPoints,
+ bigSegments,
+ bigDownloadBBoxes,
+ bigMinDownloadDistance
+ )
+ bigDownloadBBoxes
+ .map { it.enclosingTilesRect(ApplicationConstants.DOWNLOAD_TILE_ZOOM) }
+ .forEach {
+ assertEquals(
+ centerTileX - 2,
+ it.left,
+ "$it scheduled for download does not start two left of center column"
+ )
+ assertEquals(
+ centerTileX + 2,
+ it.right,
+ "$it scheduled for download does not end two right of center column"
+ )
+ }
+ }
+
+ @Test
+ fun lineCloseToCornerDownloadsAdjacentTile() = runBlocking {
+ /*
+ a line barely not touching the corner of a tile should download all four tiles around
+ the corner - even if one of them is not touched by the line itself
+
+ this test takes the four tiles around the origin LatLon(0.0, 0.0), named NW, NE, SE, SW
+ in clockwise direction
+
+ a diagonal line from south west to north east just below the origin would cross the tiles
+ NE, SE and SW, but not NW
+
+ if minDownloadDistance is greater than the distance of the line to the origin, NW should
+ still be downloaded; if not by some margin, it should be omitted
+ */
+
+ // at zoom level 16, one tile is 0.005° wide / high, or ~500m wide / high on equator
+ require(ApplicationConstants.DOWNLOAD_TILE_ZOOM == 16)
+ val lineOriginDistance = 100.0
+ val startPoint = LatLon(-0.002, -0.002).translate(lineOriginDistance, 135.0)
+ val endPoint = LatLon(0.002, 0.002).translate(lineOriginDistance, 135.0)
+ val nWCenterPoint = LatLon(0.0025, -0.0025)
+
+ val originalTrackPoints = arrayListOf(
+ startPoint.toTrackPoint(),
+ endPoint.toTrackPoint(),
+ )
+ val inputGpx = minimalGpxBuilder(originalTrackPoints)
+
+ // area around line should touch NW
+ val touchingMinDownloadDistance = lineOriginDistance + 0.001
+ val (touchingSegments, touchingDownloadBBoxes) = import(
+ touchingMinDownloadDistance,
+ inputGpx
+ )
+ assertInvariants(
+ originalTrackPoints,
+ touchingSegments,
+ touchingDownloadBBoxes,
+ touchingMinDownloadDistance
+ )
+ assertTrue(
+ touchingDownloadBBoxes.any { it.contains(nWCenterPoint) },
+ "north west center point not contained"
+ )
+
+ // area around line should not touch NW
+ val noTouchMinDownloadDistance = lineOriginDistance / 2
+ val (noTouchSegments, noTouchDownloadBBoxes) = import(noTouchMinDownloadDistance, inputGpx)
+ assertInvariants(
+ originalTrackPoints,
+ noTouchSegments,
+ noTouchDownloadBBoxes,
+ noTouchMinDownloadDistance
+ )
+ assertTrue(
+ !noTouchDownloadBBoxes.any { it.contains(nWCenterPoint) },
+ "north west center point contained even if it should not"
+ )
+ }
+
+ @Test
+ fun worksAroundEquator() = runBlocking {
+ val originalTrackPoints = arrayListOf(
+ TrackPoint("0.0", "73.1"),
+ TrackPoint("-0.3", "74.2"),
+ )
+ val minDownloadDistance = 100.0
+
+ val inputGpx = minimalGpxBuilder(originalTrackPoints)
+ val (segments, downloadBBoxes) = import(minDownloadDistance, inputGpx)
+ assertInvariants(
+ originalTrackPoints,
+ segments,
+ downloadBBoxes,
+ minDownloadDistance
+ )
+ }
+
+ @Test
+ fun worksWithWrapAroundLongitude() = runBlocking {
+ val originalTrackPoints = arrayListOf(
+ TrackPoint("-33.0", "164.9"),
+ TrackPoint("-35.1", "-170.3"),
+ )
+ val minDownloadDistance = 100.0
+
+ val inputGpx = minimalGpxBuilder(originalTrackPoints)
+ val (segments, downloadBBoxes) = import(minDownloadDistance, inputGpx)
+ assertInvariants(
+ originalTrackPoints,
+ segments,
+ downloadBBoxes,
+ minDownloadDistance
+ )
+ }
+
+ @Test
+ fun worksAroundNorthPole() = runBlocking {
+ // TODO sgr: would actually like to run test with lat: 90.0, but this fails
+ // latitude should be between -85.0511 and 85.0511 to be within OSM
+ // tiles apparently, see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
+ // TilesRect.lat2tile gives negative numbers for latitude > 85.08, which leads to huge
+ // number of tiles => maybe LatLon.checkValidity should require latitude to be <= 85.0?
+ val originalTrackPoints = arrayListOf(
+ TrackPoint("83.0", "-44.1"),
+ TrackPoint("84.0", "178.2"),
+ )
+ val minDownloadDistance = 500.0
+
+ val inputGpx = minimalGpxBuilder(originalTrackPoints)
+ val (segments, downloadBBoxes) = import(minDownloadDistance, inputGpx)
+ assertInvariants(
+ originalTrackPoints,
+ segments,
+ downloadBBoxes,
+ minDownloadDistance
+ )
+ }
+
+ private suspend fun import(
+ minDownloadDistance: Double,
+ inputGpx: String,
+ ): Pair>, List> {
+ return coroutineScope {
+ val importer = GpxImporter(minDownloadDistance)
+ val segments = async {
+ val segments = arrayListOf>()
+ importer.segments.collect { segments.add(it.toList()) }
+ segments
+ }
+ val bBoxes = async {
+ importer.downloadBBoxes.toList()
+ }
+ importer.readFile(this, inputGpx.byteInputStream())
+ return@coroutineScope Pair(segments.await(), bBoxes.await())
+ }
+ }
+
+ private fun assertInvariants(
+ originalTrackPoints: List,
+ segments: List>,
+ downloadBBoxes: List,
+ minDownloadDistance: Double,
+ ) {
+ originalTrackPoints.forEach { trackPoint ->
+ assertTrue(
+ segments.any { it.contains(trackPoint.toLatLon()) },
+ "originalTrackPoint $trackPoint not contained in displayed segments"
+ )
+ assertTrue(
+ downloadBBoxes.any { it.contains(trackPoint.toLatLon()) },
+ "originalTrackPoint $trackPoint not contained in area to download"
+ )
+ for (testAngle in 0..360 step 10) {
+ val testPoint =
+ trackPoint.toLatLon().translate(minDownloadDistance, testAngle.toDouble())
+ assertTrue(
+ downloadBBoxes.any { it.contains(testPoint) },
+ "$testPoint <= $minDownloadDistance away from $trackPoint not included in area to download"
+ )
+ }
+
+ }
+ }
+}
diff --git a/app/src/androidTest/java/de/westnordost/streetcomplete/data/import/TestHelpers.kt b/app/src/androidTest/java/de/westnordost/streetcomplete/data/import/TestHelpers.kt
new file mode 100644
index 0000000000..a04458e724
--- /dev/null
+++ b/app/src/androidTest/java/de/westnordost/streetcomplete/data/import/TestHelpers.kt
@@ -0,0 +1,39 @@
+package de.westnordost.streetcomplete.data.import
+
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flatMapConcat
+import kotlinx.coroutines.flow.toList
+
+data class TrackPoint(
+ val lat: String,
+ val lon: String,
+)
+
+internal fun LatLon.toTrackPoint(): TrackPoint {
+ return TrackPoint(this.latitude.toString(), this.longitude.toString())
+}
+
+internal fun TrackPoint.toLatLon(): LatLon {
+ return LatLon(this.lat.toDouble(), this.lon.toDouble())
+}
+
+// explicitly not using LatLon to allow testing wrong and special string formatting
+internal fun minimalGpxBuilder(trackPoints: List): String = buildString {
+ append("")
+ append("")
+ append("")
+ trackPoints.forEach {
+ append("")
+ }
+ append("")
+ append("")
+ append("")
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal suspend fun parseGpx(input: String): List {
+ // make sure flows are exhausted in correct order
+ return parseGpxFile(input.byteInputStream()).flatMapConcat { it }.toList()
+}
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 08d8b19349..10b256e2e3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -46,7 +46,8 @@
android:windowSoftInputMode="adjustResize"
android:name="de.westnordost.streetcomplete.screens.MainActivity"
android:configChanges="orientation|screenSize|screenLayout"
- android:exported="true">
+ android:exported="true"
+ android:documentLaunchMode="never">
@@ -73,6 +74,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
,
+ requestedTiles: Set? = null,
+) {
+ val boundingBox = polygon.enclosingBoundingBox()
+ val tiles = boundingBox.enclosingTilesRect(ApplicationConstants.DOWNLOAD_TILE_ZOOM)
+ .asTilePosSequence()
+ val numberOfTiles = tiles.count()
+ val requestedTiles = when (requestedTiles) {
+ null -> {
+ tiles.toHashSet()
+ }
+
+ else -> {
+ requestedTiles
+ }
+ }
+}
+
+class GpxImporter(
+ minDownloadDistance: Double,
+) {
+ /* Algorithm overview:
+ * Given that two resampled points A and B are at most 2 * minDownloadDistance away from each
+ * other and any track point between them is at most minDownloadDistance away from either A or B,
+ * an area that fully contains the track between A and B is given by a square S_track centered
+ * on the middle point between A and B, with side length 2 * minDownloadDistance and rotated
+ * such that two of its sides align with the vector from A to B. As we need to cover the area
+ * within minDownloadDistance of any track point (which might lie almost on the edge of S_track),
+ * a square S_min centered and rotated the same as S_track, but with
+ * side length = 4 * minDownloadDistance is a handy upper bound.
+ *
+ * If we download two north-south aligned squares centered on A and B, they are guaranteed to
+ * contain S_min if their side length is at least 4 * minDownloadDistance / sqrt(2) - the worst
+ * case being were S_min is rotated 45 degrees with respect to the aligned squares.
+ */
+ private val maxSampleDistance = 2 * minDownloadDistance
+ private val coveringSquareHalfLength = 2 * minDownloadDistance / sqrt(2.0)
+
+ private val _mutableSegments = MutableSharedFlow?>()
+ private val _sharedSegments = _mutableSegments.asSharedFlow()
+
+ val segments = _sharedSegments.takeWhile { it != null }.filterNotNull()
+ .map { flow -> flow.takeWhile { it != null }.filterNotNull() }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val downloadBBoxes = _sharedSegments.takeWhile { it != null }.filterNotNull()
+ .map { flow ->
+ flow.takeWhile { it != null }.filterNotNull()
+ .addInterpolatedPoints(maxSampleDistance)
+ .discardRedundantPoints(maxSampleDistance)
+ .mapToCenteredSquares(coveringSquareHalfLength)
+ .determineBBoxes()
+ }
+ .flattenConcat()
+ .mergeBBoxes()
+ .map { it.boundingBox }
+
+ fun readFile(scope: CoroutineScope, inputStream: InputStream) {
+ scope.launch {
+ parseGpxFile(inputStream).collect { segment ->
+ val m = MutableSharedFlow()
+ _mutableSegments.emit(m.asSharedFlow())
+ segment.collect {
+ m.emit(it)
+ }
+ m.emit(null)
+ }
+
+ _mutableSegments.emit(null)
+ }
+ }
+}
+
+/**
+ * Merge bounding boxes to save download calls in trade for a few more unique tiles
+ * downloaded.
+ *
+ * The algorithm merges adjacent boxes if the merged box still has a good enough ratio
+ * of actually requested vs total number of tiles downloaded.
+ */
+internal fun Flow.mergeBBoxes(): Flow {
+ return flow {
+ lateinit var mergedBBox: DecoratedBoundingBox
+ var initialized = false
+ collect {
+ if (initialized) {
+ val candidateBBox = DecoratedBoundingBox(
+ mergedBBox.polygon + it.polygon,
+ mergedBBox.requestedTiles.plus(it.tiles)
+ )
+ val requestedRatio =
+ candidateBBox.requestedTiles.size.toDouble() / candidateBBox.numberOfTiles
+ Log.d(TAG, "requestedRatio = $requestedRatio")
+ // requestedRatio >= 0.75 is a good compromise, as this allows downloading three
+ // neighbouring tiles at zoom level x in a single call at level x-1
+ mergedBBox = if (requestedRatio >= 0.75) {
+ candidateBBox
+ } else {
+ emit(mergedBBox)
+ it
+ }
+ } else {
+ mergedBBox = it
+ initialized = true
+ }
+ }
+ if (initialized) {
+ emit(mergedBBox)
+ }
+ }
+}
+
+/**
+ * Reduce a flow of bounding boxes by
+ * - dropping boxes which don't contribute additional tiles to download
+ * - merging adjacent boxes if no additional tiles are contained in the merged box
+ *
+ * the mapped boxes are also decorated with some cached data for future processing.
+ */
+internal fun Flow.determineBBoxes(): Flow {
+ val inputFlow = this.map { DecoratedBoundingBox(it.toPolygon()) }.withIndex()
+ val uniqueTilesToDownload = HashSet()
+
+ return flow {
+ lateinit var currentBBox: DecoratedBoundingBox
+ var initialized = false
+ inputFlow.collect {
+ val newBBox = it.value
+ if (!initialized) {
+ currentBBox = newBBox
+ uniqueTilesToDownload.addAll(currentBBox.tiles)
+ initialized = true
+ } else if (newBBox.tiles.all { bBox -> bBox in uniqueTilesToDownload }) {
+ Log.d(TAG, "omit bounding box #$it.index, all tiles already scheduled for download")
+ } else {
+ val extendedBBox = DecoratedBoundingBox(currentBBox.polygon + newBBox.polygon)
+ currentBBox = if (
+ extendedBBox.numberOfTiles <= (currentBBox.tiles + newBBox.tiles).toHashSet().size
+ ) {
+ // no additional tile needed to extend the polygon and download newBBox together with currentBBox
+ Log.d(TAG, "extend currentBBox with bounding box #$it.index")
+ extendedBBox
+ } else {
+ Log.d(TAG, "retain currentBBox, start new with bounding box #$it.index")
+ emit(currentBBox)
+ uniqueTilesToDownload.addAll(currentBBox.tiles)
+ newBBox
+ }
+ }
+ }
+ if (initialized) {
+ emit(currentBBox)
+ }
+ }
+}
+
+/**
+ * Transform a flow of points to a flow of north-south aligned bounding boxes centered on
+ * these points.
+ *
+ * @param halfSideLength > 0.0, in meters
+ */
+internal fun Flow.mapToCenteredSquares(halfSideLength: Double): Flow {
+ require(halfSideLength > 0.0) {
+ "halfSideLength has to be positive"
+ }
+ return map {
+ arrayListOf(
+ it.translate(halfSideLength, 0.0),
+ it.translate(halfSideLength, 90.0),
+ it.translate(halfSideLength, 180.0),
+ it.translate(halfSideLength, 270.0)
+ ).enclosingBoundingBox()
+ }
+}
+
+/**
+ * Ensure points are at most samplingDistance away from each other.
+ *
+ * Given two consecutive points A, B which are more than samplingDistance away from each other,
+ * add intermediate points on the line from A to B, samplingDistance away from each other until the
+ * last one is <= samplingDistance away from B.
+ * A and B are always retained, even if they are < samplingDistance away from each other.
+ *
+ * @param samplingDistance > 0.0, in meters
+ */
+internal fun Flow.addInterpolatedPoints(samplingDistance: Double): Flow =
+ flow {
+ require(samplingDistance > 0.0) {
+ "samplingDistance has to be positive"
+ }
+
+ lateinit var lastPoint: LatLon
+ var initialized = false
+ collect {
+ if (initialized) {
+ this.emitAll(interpolate(lastPoint, it, samplingDistance))
+ } else {
+ initialized = true
+ }
+ lastPoint = it
+ }
+ if (initialized) {
+ emit(lastPoint)
+ }
+ }
+
+/**
+ * Interpolate points between start (included) and end (not included)
+ *
+ * Returned points are samplingDistance away from each other and on the line between start and end.
+ * The last returned point is <= samplingDistance away from end.
+ *
+ * @param samplingDistance > 0.0, in meters
+ */
+private fun interpolate(start: LatLon, end: LatLon, samplingDistance: Double): Flow = flow {
+ require(samplingDistance > 0.0) {
+ "samplingDistance has to be positive"
+ }
+
+ var intermediatePoint = start
+ while (true) {
+ emit(intermediatePoint)
+ if (intermediatePoint.distanceTo(end) <= samplingDistance) {
+ break
+ }
+ intermediatePoint = intermediatePoint.translate(
+ samplingDistance,
+ intermediatePoint.initialBearingTo(end)
+ )
+ }
+}
+
+/**
+ * Discard redundant points, such that no three adjacent points A, B, C remain where B is less than
+ * samplingDistance away from both A and C
+ *
+ * @param samplingDistance > 0.0, in meters
+ */
+internal fun Flow.discardRedundantPoints(samplingDistance: Double): Flow = flow {
+ require(samplingDistance > 0.0) {
+ "samplingDistance has to be positive"
+ }
+
+ lateinit var lastRetainedPoint: LatLon
+ lateinit var candidatePoint: LatLon
+ var initializedLastRetainedPoint = false
+ var initializedCandidatePoint = false
+ collect {
+ if (!initializedLastRetainedPoint) {
+ lastRetainedPoint = it
+ initializedLastRetainedPoint = true
+ emit(it)
+ } else if (!initializedCandidatePoint) {
+ candidatePoint = it
+ initializedCandidatePoint = true
+ } else {
+ val currentPoint = it
+ if (lastRetainedPoint.distanceTo(candidatePoint) < samplingDistance
+ && candidatePoint.distanceTo(currentPoint) < samplingDistance
+ ) {
+ // discard candidatePoint
+ } else {
+ lastRetainedPoint = candidatePoint
+ emit(lastRetainedPoint)
+ }
+ candidatePoint = currentPoint
+ }
+ }
+ if (initializedCandidatePoint) {
+ emit(candidatePoint)
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/import/GpxImportParse.kt b/app/src/main/java/de/westnordost/streetcomplete/data/import/GpxImportParse.kt
new file mode 100644
index 0000000000..56478121c1
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/data/import/GpxImportParse.kt
@@ -0,0 +1,104 @@
+package de.westnordost.streetcomplete.data.import
+
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import java.io.InputStream
+import nl.adaptivity.xmlutil.EventType.*
+import nl.adaptivity.xmlutil.XmlReader
+import nl.adaptivity.xmlutil.core.impl.newReader
+import nl.adaptivity.xmlutil.xmlStreaming
+
+private const val TRACK_POINT = "trkpt"
+private const val SEGMENT = "trkseg"
+
+typealias TrackSegment = Flow
+
+/**
+ * Parses all track points from a GPX file.
+ *
+ * Yields consecutive segments. Track points within a TrackSegment can be interpolated meaningfully
+ * during subsequent processing, while track points from different TrackSegments cannot be assumed
+ * to be connected.
+ *
+ * @param inputStream valid XML according to http://www.topografix.com/GPX/1/1 schema
+ * Note: the caller is responsible to close the inputStream as appropriate;
+ * calls to the resulting sequence after closing the inputStream may fail.
+ * @return a sequence of TrackSegments, i.e. sequences of track points logically connected.
+ *
+ * Note: The nested sequences returned work on a single input stream, thus make sure to exhaust
+ * any TrackSegment first before proceeding to the next one. A TrackSegment will not yield any
+ * more track points once you proceed to the next TrackSegment in the outer sequence, thus e.g.
+ * result.toList().first().toList() will not yield any element, while
+ * result.flatMap { it.toList() }.toList() will.
+ */
+fun parseGpxFile(inputStream: InputStream): Flow =
+ xmlStreaming.newReader(inputStream, "UTF-8").parseGpxFile()
+
+private fun XmlReader.parseGpxFile(): Flow {
+ return flow {
+ var depth = 1
+
+ while (depth != 0) {
+ when (this@parseGpxFile.next()) {
+ END_ELEMENT -> {
+ depth--
+ }
+
+ START_ELEMENT -> when (localName) {
+ SEGMENT -> {
+ // segment is closed while parsing, thus depth remains the same
+ emit(
+ parseSegment(this@parseGpxFile)
+ )
+ }
+
+ else -> {
+ depth++
+ }
+ }
+
+ END_DOCUMENT -> {
+ break
+ }
+
+ else -> {}
+ }
+ }
+ }
+}
+
+private fun parseSegment(xmlReader: XmlReader): TrackSegment = flow {
+ var depth = 1
+ while (depth != 0) {
+ when (xmlReader.next()) {
+ END_ELEMENT -> {
+ if (xmlReader.localName == SEGMENT) {
+ return@flow
+ }
+
+ depth--
+ }
+
+ START_ELEMENT -> {
+ if (xmlReader.localName == TRACK_POINT) {
+ emit(
+ LatLon(
+ // TODO [sgr]: correct null check
+ xmlReader.getAttributeValue(null, "lat")!!.toDouble(),
+ xmlReader.getAttributeValue(null, "lon")!!.toDouble()
+ )
+ )
+ }
+ depth++
+ }
+
+ END_DOCUMENT -> {
+ break
+ }
+
+ else -> {}
+
+ }
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt
index a92f1c373d..c5ebe0f651 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/MainActivity.kt
@@ -162,6 +162,23 @@ class MainActivity :
mainFragment?.setCameraPosition(pos, zoom)
}
+ private fun handleGpxUri() {
+ if (intent.action !in listOf(Intent.ACTION_SEND, Intent.ACTION_VIEW)) return
+ if (intent.data != null) {
+ val data = intent.data!!
+ if (data.scheme != "content") return
+ if (intent.type in listOf("application/gpx+xml", "application/xml+gpx")
+ || (intent.type == "application/octet-stream" && data.path?.endsWith(".gpx") == true)
+ ) {
+ mainFragment?.importTrack(data)
+ }
+ } else if (intent.clipData != null) {
+ if (intent.type in listOf("application/gpx+xml", "application/xml+gpx")) {
+ mainFragment?.importTrack(intent.clipData!!.getItemAt(0).uri)
+ }
+ }
+ }
+
public override fun onStart() {
super.onStart()
@@ -290,6 +307,7 @@ class MainActivity :
override fun onMapInitialized() {
handleGeoUri()
+ handleGpxUri()
}
/* ------------------------------- TutorialFragment.Listener -------------------------------- */
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt
index fd5c22fd0d..b5d0e36377 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/MainFragment.kt
@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.PointF
import android.location.Location
+import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
@@ -33,10 +34,15 @@ import androidx.fragment.app.commit
import de.westnordost.osmfeatures.FeatureDictionary
import de.westnordost.streetcomplete.ApplicationConstants
import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.download.DownloadController
import de.westnordost.streetcomplete.data.download.tiles.asBoundingBoxOfEnclosingTiles
+import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect
import de.westnordost.streetcomplete.data.edithistory.EditKey
import de.westnordost.streetcomplete.data.edithistory.icon
+import de.westnordost.streetcomplete.data.import.GpxImporter
import de.westnordost.streetcomplete.data.messages.Message
+import de.westnordost.streetcomplete.data.meta.CountryInfos
+import de.westnordost.streetcomplete.data.meta.LengthUnit
import de.westnordost.streetcomplete.data.osm.edits.ElementEditType
import de.westnordost.streetcomplete.data.osm.edits.MapDataWithEditsSource
import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
@@ -77,6 +83,8 @@ import de.westnordost.streetcomplete.screens.main.bottom_sheet.IsMapPositionAwar
import de.westnordost.streetcomplete.screens.main.bottom_sheet.MoveNodeFragment
import de.westnordost.streetcomplete.screens.main.bottom_sheet.SplitWayFragment
import de.westnordost.streetcomplete.screens.main.controls.LocationState
+import de.westnordost.streetcomplete.screens.main.controls.GpxImportConfirmationDialog
+import de.westnordost.streetcomplete.screens.main.controls.GpxImportSettingsDialog
import de.westnordost.streetcomplete.screens.main.controls.MainMenuDialog
import de.westnordost.streetcomplete.screens.main.edithistory.EditHistoryFragment
import de.westnordost.streetcomplete.screens.main.edithistory.EditHistoryViewModel
@@ -116,12 +124,24 @@ import de.westnordost.streetcomplete.util.math.initialBearingTo
import de.westnordost.streetcomplete.util.viewBinding
import de.westnordost.streetcomplete.view.dialogs.RequestLoginDialog
import de.westnordost.streetcomplete.view.insets_animation.respectSystemInsets
+import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapConcat
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.reduce
+import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.qualifier.named
+import java.util.Locale
+import kotlin.coroutines.resume
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
@@ -165,6 +185,8 @@ class MainFragment :
// rest
ShowsGeometryMarkers {
+ private val downloadController: DownloadController by inject()
+ private val countryInfos: CountryInfos by inject()
private val visibleQuestsSource: VisibleQuestsSource by inject()
private val mapDataWithEditsSource: MapDataWithEditsSource by inject()
private val notesSource: NotesWithEditsSource by inject()
@@ -1278,6 +1300,90 @@ class MainFragment :
setIsFollowingPosition(false)
}
+ private data class DownloadStats (
+ val numberOfDownloads: Int,
+ val areaInSqkm: Double,
+ )
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun importTrack(uri: Uri) {
+ val ctx = context ?: return
+ val fragment = mapFragment ?: return
+ val lengthUnit = Locale.getDefault().country.takeIf { it.isNotEmpty() }
+ ?.let { countryInfos.get(listOf(it)) }?.lengthUnits?.first() ?: LengthUnit.METER
+
+ viewLifecycleScope.launch {
+ val importSettings = suspendCancellableCoroutine { cont ->
+ val dialog = GpxImportSettingsDialog(lengthUnit) { cont.resume(it) }
+ dialog.show(parentFragmentManager, null)
+ }
+
+ val importer = GpxImporter(importSettings.minDownloadDistance)
+
+ // read file once to find number and size of downloads, as well as original imported points
+ var downloadStatsResult: Deferred? = null
+ if(importSettings.downloadAlongTrack) {
+ downloadStatsResult = async {
+ var numberOfDownloads = 0
+ val areaInSqkm = importer.downloadBBoxes
+ .map {
+ numberOfDownloads += 1
+ it
+ }
+ .flatMapConcat { it.enclosingTilesRect(ApplicationConstants.DOWNLOAD_TILE_ZOOM).asTilePosSequence().asFlow() }
+ .distinctUntilChanged()
+ .map { it.asBoundingBox(ApplicationConstants.DOWNLOAD_TILE_ZOOM).area() }
+ .reduce { accumulator, value -> accumulator + value } / 1000000.0
+ DownloadStats(numberOfDownloads, areaInSqkm)
+ }
+ }
+
+ val newSegmentsResult = async {
+ val segments = arrayListOf>()
+ importer.segments.collect {segments.add(it.toList())}
+ segments
+ }
+
+ ctx.contentResolver?.openInputStream(uri)?.let { inputStream ->
+ importer.readFile(this, inputStream)
+ }
+
+ val downloadStats = downloadStatsResult?.await()
+ val newSegments = newSegmentsResult.await()
+
+ if (importSettings.displayTrack) {
+ fragment.replaceImportedTrack(newSegments)
+ }
+
+ if(importSettings.downloadAlongTrack) {
+ assert(downloadStats != null)
+ if (downloadStats!!.areaInSqkm > ApplicationConstants.MAX_DOWNLOADABLE_AREA_IN_SQKM * 25) {
+ context?.toast(R.string.gpx_import_download_area_too_big, Toast.LENGTH_LONG)
+ } else {
+ suspendCancellableCoroutine { cont ->
+ GpxImportConfirmationDialog(
+ ctx,
+ downloadStats.areaInSqkm,
+ downloadStats.numberOfDownloads,
+ lengthUnit
+ ) { cont.resume(it) }.show()
+ }
+
+ // read file a second time to actually download the boxes
+ val downloadJob = launch {
+ importer.downloadBBoxes
+ .collect { downloadController.download(it)}
+ }
+
+ ctx.contentResolver?.openInputStream(uri)?.let { inputStream ->
+ importer.readFile(this, inputStream)
+ }
+ downloadJob.join()
+ }
+ }
+ }
+ }
+
//endregion
companion object {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/GpxImportConfirmationDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/GpxImportConfirmationDialog.kt
new file mode 100644
index 0000000000..d4d74421aa
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/GpxImportConfirmationDialog.kt
@@ -0,0 +1,52 @@
+package de.westnordost.streetcomplete.screens.main.controls
+
+import android.content.Context
+import android.content.DialogInterface
+import android.view.LayoutInflater
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.meta.LengthUnit
+
+/** A dialog to confirm download of (potentially massive) data along imported GPX track */
+class GpxImportConfirmationDialog(
+ context: Context,
+ areaToDownloadInSqkm: Double,
+ numberOfDownloads: Int,
+ lengthUnit: LengthUnit,
+ private val callback: (confirm: Boolean) -> Unit,
+) : AlertDialog(context) {
+
+ init {
+
+ val view =
+ LayoutInflater.from(context).inflate(R.layout.dialog_gpx_import_confirmation, null)
+ setView(view)
+
+ val formattedArea = when (lengthUnit) {
+ LengthUnit.FOOT_AND_INCH -> "%.0f acres".format(areaToDownloadInSqkm * ACRES_IN_SQUARE_KILOMETER)
+ else -> "%.1f km²".format(areaToDownloadInSqkm)
+ }
+
+ val downloadsToScheduleTextView = view.findViewById(R.id.downloadsToScheduleText)
+ downloadsToScheduleTextView.text = context.getString(
+ R.string.gpx_import_number_of_downloads_to_schedule,
+ numberOfDownloads
+ )
+ val areaToDownloadTextView = view.findViewById(R.id.areaToDownloadText)
+ areaToDownloadTextView.text =
+ context.getString(R.string.gpx_import_area_to_download, formattedArea)
+
+ setButton(DialogInterface.BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ ->
+ callback(true)
+ dismiss()
+ }
+ setButton(BUTTON_NEGATIVE, context.getString(android.R.string.cancel)) { _, _ ->
+ cancel()
+ }
+ }
+
+ companion object {
+ private const val ACRES_IN_SQUARE_KILOMETER = 247.105
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/GpxImportSettingsDialog.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/GpxImportSettingsDialog.kt
new file mode 100644
index 0000000000..e9daa5de58
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/controls/GpxImportSettingsDialog.kt
@@ -0,0 +1,114 @@
+package de.westnordost.streetcomplete.screens.main.controls
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.slider.LabelFormatter
+import de.westnordost.streetcomplete.R
+import de.westnordost.streetcomplete.data.meta.LengthUnit
+import de.westnordost.streetcomplete.databinding.DialogGpxImportSettingsBinding
+import de.westnordost.streetcomplete.util.ktx.viewLifecycleScope
+import de.westnordost.streetcomplete.util.viewBinding
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.launch
+
+data class GpxImportSettings(
+ val displayTrack: Boolean,
+ val downloadAlongTrack: Boolean,
+ val minDownloadDistance: Double,
+)
+
+/** A dialog to specify GPX import settings */
+class GpxImportSettingsDialog(
+ private val lengthUnit: LengthUnit,
+ private val callback: (GpxImportSettings) -> Unit,
+) : DialogFragment(R.layout.dialog_gpx_import_settings) {
+ private val binding by viewBinding(DialogGpxImportSettingsBinding::bind)
+ private var worker: Deferred>? = null
+
+ private val minDownloadDistanceOptions: List = listOf(10.0, 100.0, 250.0, 500.0)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.minDownloadDistanceSlider.setLabelFormatter {
+ formatMinDownloadDistance(it.toInt())
+ }
+ binding.minDownloadDistanceSlider.addOnChangeListener { _, value, _ ->
+ updateDownloadCheckboxLabel(value.toInt())
+ }
+ binding.minDownloadDistanceSlider.value = INITIAL_MIN_DOWNLOAD_DISTANCE_INDEX.toFloat()
+ updateDownloadCheckboxLabel(INITIAL_MIN_DOWNLOAD_DISTANCE_INDEX)
+
+ binding.downloadCheckBox.setOnClickListener {
+ if (binding.downloadCheckBox.isChecked) {
+ binding.minDownloadDistanceSlider.visibility = View.VISIBLE
+ binding.minDownloadDistanceSlider.labelBehavior = LabelFormatter.LABEL_VISIBLE
+ } else {
+ binding.minDownloadDistanceSlider.visibility = View.GONE
+ binding.minDownloadDistanceSlider.labelBehavior = LabelFormatter.LABEL_GONE
+ }
+ updateOkButtonState()
+ }
+
+ binding.displayTrackCheckBox.setOnClickListener {
+ updateOkButtonState()
+ }
+ binding.okButton.setOnClickListener {
+ viewLifecycleScope.launch {
+ callback(
+ GpxImportSettings(
+ binding.displayTrackCheckBox.isChecked,
+ binding.downloadCheckBox.isChecked,
+ minDownloadDistanceInMeters()
+ )
+ )
+ dismiss()
+ }
+ }
+ binding.cancelButton.setOnClickListener {
+ worker?.cancel()
+ dismiss()
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ worker?.cancel()
+ }
+
+ private fun updateDownloadCheckboxLabel(index: Int) {
+ binding.downloadCheckBox.text =
+ getString(
+ R.string.gpx_import_download_along_track,
+ formatMinDownloadDistance(index)
+ )
+ }
+
+ private fun updateOkButtonState() {
+ binding.okButton.isEnabled =
+ binding.displayTrackCheckBox.isChecked || binding.downloadCheckBox.isChecked
+ }
+
+ private fun formatMinDownloadDistance(index: Int): String {
+ val minDownloadDistance = minDownloadDistanceOptions[index].toInt()
+ return when (lengthUnit) {
+ LengthUnit.FOOT_AND_INCH -> "${minDownloadDistance}yd"
+ else -> "${minDownloadDistance}m"
+ }
+ }
+
+ private fun minDownloadDistanceInMeters(): Double {
+ val minDownloadDistance =
+ minDownloadDistanceOptions[binding.minDownloadDistanceSlider.value.toInt()]
+ return when (lengthUnit) {
+ LengthUnit.FOOT_AND_INCH -> minDownloadDistance * YARDS_IN_METER
+ else -> minDownloadDistance
+ }
+ }
+
+ companion object {
+ private const val INITIAL_MIN_DOWNLOAD_DISTANCE_INDEX = 1
+ private const val YARDS_IN_METER = 0.9144
+ }
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt
index 03683d7d00..048656606c 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/MainMapFragment.kt
@@ -29,6 +29,7 @@ import de.westnordost.streetcomplete.screens.main.map.components.CurrentLocation
import de.westnordost.streetcomplete.screens.main.map.components.DownloadedAreaMapComponent
import de.westnordost.streetcomplete.screens.main.map.components.FocusGeometryMapComponent
import de.westnordost.streetcomplete.screens.main.map.components.GeometryMarkersMapComponent
+import de.westnordost.streetcomplete.screens.main.map.components.ImportedTracksMapComponent
import de.westnordost.streetcomplete.screens.main.map.components.PinsMapComponent
import de.westnordost.streetcomplete.screens.main.map.components.SelectedPinsMapComponent
import de.westnordost.streetcomplete.screens.main.map.components.StyleableOverlayMapComponent
@@ -86,6 +87,7 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers {
private var downloadedAreaManager: DownloadedAreaManager? = null
private var locationMapComponent: CurrentLocationMapComponent? = null
private var tracksMapComponent: TracksMapComponent? = null
+ private var importedTracksMapComponent: ImportedTracksMapComponent? = null
interface Listener {
fun onClickedQuest(questKey: QuestKey)
@@ -204,6 +206,9 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers {
tracksMapComponent = TracksMapComponent(context, style, map)
viewLifecycleOwner.lifecycle.addObserver(tracksMapComponent!!)
+ importedTracksMapComponent = ImportedTracksMapComponent(map)
+ viewLifecycleOwner.lifecycle.addObserver(importedTracksMapComponent!!)
+
pinsMapComponent = PinsMapComponent(context, context.contentResolver, map, mapImages!!, ::onClickPin)
geometryMapComponent = FocusGeometryMapComponent(context.contentResolver, map)
viewLifecycleOwner.lifecycle.addObserver(geometryMapComponent!!)
@@ -235,6 +240,7 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers {
downloadedAreaMapComponent?.layers,
styleableOverlayMapComponent?.layers,
tracksMapComponent?.layers,
+ importedTracksMapComponent?.layers,
).flatten()) {
style.addLayerBelow(layer, firstLabelLayer)
}
@@ -277,6 +283,8 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers {
val positionsLists = tracks.map { track -> track.map { it.position } }
tracksMapComponent?.setTracks(positionsLists, isRecordingTracks)
+ // TODO [sgr]: adapt to MapLibre
+// importedTrackMapComponent = ImportedTrackMapComponent(ctrl)
}
override fun onStop() {
@@ -585,6 +593,11 @@ class MainMapFragment : MapFragment(), ShowsGeometryMarkers {
private const val CLICK_AREA_SIZE_IN_DP = 28
}
+
+ /* -------------------------------- Show imported tracks ------------------------------------ */
+ fun replaceImportedTrack(pointsList: List>) {
+ importedTracksMapComponent?.replaceImportedTrack(pointsList)
+ }
}
private fun ArrayList>.takeLastNested(n: Int): ArrayList> {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/ImportedTracksMapComponent.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/ImportedTracksMapComponent.kt
new file mode 100644
index 0000000000..41b4505866
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/screens/main/map/components/ImportedTracksMapComponent.kt
@@ -0,0 +1,69 @@
+package de.westnordost.streetcomplete.screens.main.map.components
+
+import androidx.annotation.UiThread
+import androidx.lifecycle.DefaultLifecycleObserver
+import com.google.gson.JsonObject
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.screens.main.map.maplibre.clear
+import org.maplibre.android.maps.MapLibreMap
+import org.maplibre.android.style.expressions.Expression.*
+import org.maplibre.android.style.layers.Layer
+import org.maplibre.android.style.layers.LineLayer
+import org.maplibre.android.style.layers.Property
+import org.maplibre.android.style.layers.PropertyFactory.*
+import org.maplibre.android.style.sources.GeoJsonSource
+import org.maplibre.geojson.Feature
+import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.LineString
+import org.maplibre.geojson.Point
+
+/** Shows imported tracks on the map */
+class ImportedTracksMapComponent(map: MapLibreMap) :
+ DefaultLifecycleObserver {
+ private val importedTracksSource = GeoJsonSource("imported-tracks-source")
+
+ private var importedTracks: MutableList> = arrayListOf()
+
+ val layers: List = listOf(
+ LineLayer("imported-tracks", "imported-tracks-source")
+ .withProperties(
+ lineColor("#147d14"),
+ lineWidth(6f),
+ linePattern(literal("trackImg")),
+ lineOpacity(0.6f),
+ lineCap(Property.LINE_CAP_ROUND),
+ lineDasharray(arrayOf(0f, 2f))
+ ),
+ )
+
+ init {
+ importedTracksSource.isVolatile = true // TODO [sgr]: check if this is the correct setting
+ map.style?.addSource(importedTracksSource)
+ }
+
+ @UiThread
+ fun clear() {
+ importedTracks.clear()
+ importedTracksSource.clear()
+ }
+
+ private fun updateImportedTracks() {
+ val features = importedTracks.map { it.toLineFeature() }
+ importedTracksSource.setGeoJson(FeatureCollection.fromFeatures(features))
+ }
+
+ @UiThread
+ fun replaceImportedTrack(
+ pointsList: List>,
+ ) {
+ require(pointsList.isNotEmpty())
+ importedTracks = pointsList.map { it.toMutableList() }.toMutableList()
+ updateImportedTracks()
+ }
+}
+
+private fun List.toLineFeature(): Feature {
+ val line = LineString.fromLngLats(map { Point.fromLngLat(it.longitude, it.latitude) })
+ val p = JsonObject()
+ return Feature.fromGeometry(line, p)
+}
diff --git a/app/src/main/res/layout/dialog_gpx_import_confirmation.xml b/app/src/main/res/layout/dialog_gpx_import_confirmation.xml
new file mode 100644
index 0000000000..148d065d04
--- /dev/null
+++ b/app/src/main/res/layout/dialog_gpx_import_confirmation.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_gpx_import_settings.xml b/app/src/main/res/layout/dialog_gpx_import_settings.xml
new file mode 100644
index 0000000000..293a3c5a6e
--- /dev/null
+++ b/app/src/main/res/layout/dialog_gpx_import_settings.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/raw/languages.yml b/app/src/main/res/raw/languages.yml
index a1e6e0e8f9..bf57c58822 100644
--- a/app/src/main/res/raw/languages.yml
+++ b/app/src/main/res/raw/languages.yml
@@ -8,7 +8,7 @@
- da
- de
- el
-- en
+- en-US
- en-AU
- en-GB
- eo
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 72c14fb06e..2dd7a0c9af 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -599,6 +599,17 @@ Before uploading your changes, the app checks with a <a href=\"https://www.we
Delete cache
If you think some data is outdated
+
+ Import GPX track
+ Display track on the map
+ Download data within %s along track
+ %.0fm
+
+ Confirm download
+ %d downloads will be scheduled.
+ %s will be downloaded.
+
+ Please import a shorter track or set a smaller download area
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 4a67b31d30..09d18f7c92 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -92,6 +92,10 @@
- @color/accent
+
+
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportAddInterpolatedPointsTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportAddInterpolatedPointsTest.kt
new file mode 100644
index 0000000000..49032237c8
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportAddInterpolatedPointsTest.kt
@@ -0,0 +1,111 @@
+package de.westnordost.streetcomplete.data.import
+
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.util.math.distanceTo
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.count
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertFails
+import kotlin.test.assertTrue
+
+class GpxImportAddInterpolatedPointsTest {
+ @Test
+ fun `fails with bad parameters`() = runTest {
+ assertFails {
+ emptyFlow().addInterpolatedPoints(-15.0).count()
+ }
+ }
+
+ @Test
+ fun `gracefully handles small flows`() = runTest {
+ assertEquals(
+ 0,
+ emptyFlow().addInterpolatedPoints(1.0).count(),
+ "empty flow invents points"
+ )
+ assertEquals(
+ 1,
+ flowOf(LatLon(89.9, 27.1)).addInterpolatedPoints(77.0).count(),
+ "size 1 flow invents points"
+ )
+ }
+
+ @Test
+ fun `does not add unnecessary points`() = runTest {
+ val originalPoints = listOf(LatLon(0.0, 0.0), LatLon(0.1, -0.5), LatLon(1.0, -1.0))
+ val samplingDistance =
+ originalPoints.zipWithNext().maxOf { it.first.distanceTo(it.second) }
+ val interpolatedPoints =
+ originalPoints.asFlow().addInterpolatedPoints(samplingDistance).toList()
+ assertEquals(originalPoints.size, interpolatedPoints.size)
+ }
+
+ @Test
+ fun `ensures promised sampling distance on single segment`() = runTest {
+ val p1 = LatLon(-11.1, 36.7)
+ val p2 = LatLon(-89.0, 61.0)
+ val numIntermediatePoints = 100
+ val samplingDistance = p1.distanceTo(p2) / (numIntermediatePoints + 1)
+ val originalPoints = listOf(p1, p2)
+ val interpolatedPoints =
+ originalPoints.asFlow().addInterpolatedPoints(samplingDistance).toList()
+ assertEquals(
+ numIntermediatePoints + 2,
+ interpolatedPoints.size,
+ "wrong number of points created"
+ )
+ assertCorrectSampling(originalPoints, interpolatedPoints, samplingDistance)
+ }
+
+ @Test
+ fun `ensures promised sampling distance on multiple segments`() = runTest {
+ val originalPoints =
+ listOf(LatLon(0.0, 0.0), LatLon(1.0, 1.0), LatLon(2.1, 1.3), LatLon(0.0, 0.0))
+ val samplingDistance =
+ originalPoints.zipWithNext().minOf { it.first.distanceTo(it.second) } / 100
+ val interpolatedPoints =
+ originalPoints.asFlow().addInterpolatedPoints(samplingDistance).toList()
+ assertCorrectSampling(originalPoints, interpolatedPoints, samplingDistance)
+ }
+
+ private fun assertCorrectSampling(
+ originalPoints: List,
+ interpolatedPoints: List,
+ samplingDistance: Double,
+ ) {
+ // some tolerance is needed due to rounding errors
+ val maxToleratedDistance = samplingDistance * 1.001
+ val minToleratedDistance = samplingDistance * 0.999
+
+ val distances = interpolatedPoints.zipWithNext().map { it.first.distanceTo(it.second) }
+ distances.forEachIndexed { index, distance ->
+ assertTrue(
+ distance <= maxToleratedDistance,
+ "distance between consecutive points too big; $distance > $maxToleratedDistance"
+ )
+
+ // the only distance that may be smaller than samplingDistance is between the last
+ // interpolated point of one segment and the original point starting the next segment
+ if (distance < minToleratedDistance) {
+ assertContains(
+ originalPoints, interpolatedPoints[index + 1],
+ "distance between two interpolated points too small ($distance < $minToleratedDistance"
+ )
+ }
+
+ }
+
+ originalPoints.forEach {
+ assertContains(
+ interpolatedPoints, it,
+ "original point $it is missing in interpolated points"
+ )
+ }
+ }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportDetermineBBoxesTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportDetermineBBoxesTest.kt
new file mode 100644
index 0000000000..c854ef6510
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportDetermineBBoxesTest.kt
@@ -0,0 +1,108 @@
+package de.westnordost.streetcomplete.data.import
+
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.util.math.area
+import de.westnordost.streetcomplete.util.math.isCompletelyInside
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.count
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class GpxImportDetermineBBoxesTest {
+ @Test
+ fun `gracefully handles empty flow`() = runTest {
+ assertEquals(
+ 0,
+ emptyFlow().determineBBoxes().count(),
+ "empty flow not retained"
+ )
+ }
+
+ @Test
+ fun `drops duplicates`() = runTest {
+ val inputBoxes = listOf(
+ BoundingBox(LatLon(-0.01, -0.01), LatLon(0.0, 0.0)),
+ BoundingBox(LatLon(-0.01, -0.01), LatLon(0.0, 0.0)),
+ )
+ val downloadBoxes = inputBoxes.asFlow().determineBBoxes().toList()
+ assertEquals(
+ 1,
+ downloadBoxes.size,
+ "failed to merge all boxes into one"
+ )
+ val downloadBBox = downloadBoxes[0].boundingBox
+ inputBoxes.forEach {
+ assertTrue(
+ it.isCompletelyInside(downloadBBox),
+ "input bounding box $it is not contained in download box $downloadBBox"
+ )
+ }
+ assertEquals(
+ inputBoxes[0].area(),
+ downloadBBox.area(),
+ 1.0,
+ "area to download is not the same as area of one input box"
+ )
+ }
+
+ @Test
+ fun `merges adjacent boxes forming a rectangle`() = runTest {
+ val inputBoxes = listOf(
+ BoundingBox(LatLon(0.00, 0.0), LatLon(0.01, 0.01)),
+ BoundingBox(LatLon(0.01, 0.0), LatLon(0.02, 0.01)),
+ BoundingBox(LatLon(0.02, 0.0), LatLon(0.03, 0.01)),
+ )
+ val downloadBoxes = inputBoxes.asFlow().determineBBoxes().toList()
+ assertEquals(
+ 1,
+ downloadBoxes.size,
+ "failed to merge all boxes into one"
+ )
+ val downloadBBox = downloadBoxes[0].boundingBox
+ inputBoxes.forEach {
+ assertTrue(
+ it.isCompletelyInside(downloadBBox),
+ "input bounding box $it is not contained in download box $downloadBBox"
+ )
+ }
+ assertEquals(
+ inputBoxes.sumOf { it.area() },
+ downloadBBox.area(),
+ 1.0,
+ "area to download is not the same as area of input boxes"
+ )
+ }
+
+ @Test
+ fun `partially merges boxes in an L-shape`() = runTest {
+ val inputBoxes = listOf(
+ BoundingBox(LatLon(0.00, 0.00), LatLon(0.01, 0.01)),
+ BoundingBox(LatLon(0.01, 0.00), LatLon(0.02, 0.01)),
+ BoundingBox(LatLon(0.00, 0.01), LatLon(0.01, 0.06)),
+ )
+ val downloadBoxes = inputBoxes.asFlow().determineBBoxes().toList()
+ assertEquals(
+ 2,
+ downloadBoxes.size,
+ "failed to merge one side of the L-shape"
+ )
+ val downloadBBoxes = downloadBoxes.map { it.boundingBox }
+ inputBoxes.forEach {
+ assertTrue(
+ downloadBBoxes.any { downloadBBox -> it.isCompletelyInside(downloadBBox) },
+ "input bounding box $it is not contained in any download box $downloadBBoxes"
+ )
+ }
+ assertEquals(
+ inputBoxes.sumOf { it.area() },
+ downloadBBoxes.sumOf { it.area() },
+ 1.0,
+ "area to download is not the same as area of input boxes"
+ )
+ }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportDiscardRedundantPointsTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportDiscardRedundantPointsTest.kt
new file mode 100644
index 0000000000..dec21c775f
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportDiscardRedundantPointsTest.kt
@@ -0,0 +1,102 @@
+package de.westnordost.streetcomplete.data.import
+
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.util.math.distanceTo
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.count
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFails
+
+class GpxImportDiscardRedundantPointsTest {
+ @Test
+ fun `fails with bad parameters`() = runTest {
+ assertFails {
+ emptyFlow().discardRedundantPoints(-15.0).count()
+ }
+ }
+
+ @Test
+ fun `gracefully handles small flows`() = runTest {
+ assertEquals(
+ 0,
+ emptyFlow().discardRedundantPoints(1.0).count(),
+ "empty flow not retained"
+ )
+ assertEquals(
+ 1,
+ flowOf(LatLon(89.9, 27.1)).discardRedundantPoints(77.0).count(),
+ "size 1 flow not retained"
+ )
+ assertEquals(
+ 2,
+ flowOf(LatLon(-41.7, -39.8), LatLon(33.1, 78.8)).discardRedundantPoints(20.0)
+ .count(),
+ "size 2 flow not retained"
+ )
+ }
+
+ @Test
+ fun `keeps non-redundant points`() = runTest {
+ val originalPoints = listOf(LatLon(10.10, -2.0), LatLon(19.0, -54.4), LatLon(51.04, -71.30))
+ val samplingDistance = originalPoints.zipWithNext().minOf { it.first.distanceTo(it.second) }
+ val retainedPoints =
+ originalPoints.asFlow().discardRedundantPoints(samplingDistance).toList()
+ assertEquals(
+ originalPoints.size,
+ retainedPoints.size,
+ "dropping non-redundant points"
+ )
+ }
+
+ @Test
+ fun `discards single redundant point`() = runTest {
+ val originalPoints = listOf(LatLon(10.10, -2.0), LatLon(19.0, -54.4), LatLon(51.04, -71.30))
+ val epsilon = 0.00001
+ val samplingDistance =
+ originalPoints.zipWithNext().maxOf { it.first.distanceTo(it.second) } + epsilon
+ assertEquals(
+ originalPoints.size - 1,
+ originalPoints.asFlow().discardRedundantPoints(samplingDistance).count(),
+ "failed to drop redundant point"
+ )
+ }
+
+ @Test
+ fun `discards multiple adjacent redundant points`() = runTest {
+ val originalPoints = listOf(
+ LatLon(1.0, 0.0),
+ LatLon(2.0, 0.0),
+ LatLon(3.0, 0.0),
+ LatLon(4.0, 0.0)
+ )
+ val samplingDistance = originalPoints.first().distanceTo(originalPoints.last())
+ assertEquals(
+ 2,
+ originalPoints.asFlow().discardRedundantPoints(samplingDistance).count(),
+ "failed to drop redundant point"
+ )
+ }
+
+ @Test
+ fun `discards multiple non-adjacent redundant points`() = runTest {
+ val originalPoints = listOf(
+ LatLon(1.0, 0.0),
+ LatLon(2.0, 0.0),
+ LatLon(3.0, 0.0),
+ LatLon(7.0, 0.0),
+ LatLon(8.0, 0.0),
+ LatLon(9.0, 0.0),
+ )
+ val samplingDistance = originalPoints[0].distanceTo(originalPoints[2])
+ assertEquals(
+ 4,
+ originalPoints.asFlow().discardRedundantPoints(samplingDistance).count(),
+ "failed to drop redundant point"
+ )
+ }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportMapToCenteredSquaresTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportMapToCenteredSquaresTest.kt
new file mode 100644
index 0000000000..0795aa9a1f
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportMapToCenteredSquaresTest.kt
@@ -0,0 +1,43 @@
+package de.westnordost.streetcomplete.data.import
+
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.util.math.area
+import de.westnordost.streetcomplete.util.math.contains
+import kotlinx.coroutines.flow.count
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.math.pow
+import kotlin.test.assertEquals
+import kotlin.test.assertFails
+import kotlin.test.assertTrue
+
+class GpxImportMapToCenteredSquaresTest {
+ @Test
+ fun `fails with bad parameters`() = runTest {
+ assertFails {
+ emptyFlow().mapToCenteredSquares(-1.0).count()
+ }
+ }
+
+ @Test
+ fun `maps correctly`() = runTest {
+ val point = LatLon(12.0, 37.1)
+ val halfSideLength = 100.0
+ val squares = flowOf(point).mapToCenteredSquares(halfSideLength).toList()
+ assertEquals(
+ 1,
+ squares.size,
+ "expected a single bounding box, ${squares.size} returned"
+ )
+ assertTrue(squares[0].contains(point), "center not contained in bounding box")
+ assertEquals(
+ (halfSideLength * 2).pow(2),
+ squares[0].area(),
+ 10.0,
+ "area not matching"
+ )
+ }
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportMergeBBoxesTest.kt b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportMergeBBoxesTest.kt
new file mode 100644
index 0000000000..6c75bdcd7a
--- /dev/null
+++ b/app/src/test/java/de/westnordost/streetcomplete/data/import/GpxImportMergeBBoxesTest.kt
@@ -0,0 +1,76 @@
+package de.westnordost.streetcomplete.data.import
+
+import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox
+import de.westnordost.streetcomplete.data.osm.mapdata.LatLon
+import de.westnordost.streetcomplete.data.osm.mapdata.toPolygon
+import de.westnordost.streetcomplete.util.math.area
+import de.westnordost.streetcomplete.util.math.isCompletelyInside
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.count
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class GpxImportMergeBBoxesTest {
+ @Test
+ fun `gracefully handles empty flow`() = runTest {
+ assertEquals(
+ 0,
+ emptyFlow().mergeBBoxes().count(),
+ "empty flow not retained"
+ )
+ }
+
+ @Test
+ fun `merges same size squares in an L-shape`() = runTest {
+ val inputBoxes = listOf(
+ BoundingBox(LatLon(0.00, 0.00), LatLon(0.01, 0.01)),
+ BoundingBox(LatLon(0.01, 0.00), LatLon(0.02, 0.01)),
+ BoundingBox(LatLon(0.00, 0.01), LatLon(0.01, 0.02)),
+ ).map { DecoratedBoundingBox(it.toPolygon()) }
+ val downloadBoxes = inputBoxes.asFlow().mergeBBoxes().toList()
+ assertEquals(
+ 1,
+ downloadBoxes.size,
+ "failed to merge all boxes into one"
+ )
+ val downloadBBox = downloadBoxes[0].boundingBox
+ inputBoxes.map { it.boundingBox }.forEach {
+ assertTrue(
+ it.isCompletelyInside(downloadBBox),
+ "input bounding box $it is not contained in download box $downloadBBox"
+ )
+ }
+ }
+
+ @Test
+ fun `partially merges L-shape with one long leg`() = runTest {
+ val inputBoxes = listOf(
+ BoundingBox(LatLon(0.00, 0.00), LatLon(0.01, 0.01)),
+ BoundingBox(LatLon(0.01, 0.00), LatLon(0.02, 0.01)),
+ BoundingBox(LatLon(0.00, 0.01), LatLon(0.01, 0.06)),
+ ).map { DecoratedBoundingBox(it.toPolygon()) }
+ val downloadBoxes = inputBoxes.asFlow().mergeBBoxes().toList()
+ assertEquals(
+ 2,
+ downloadBoxes.size,
+ "failed to merge one side of the L-shape"
+ )
+ val downloadBBoxes = downloadBoxes.map { it.boundingBox }
+ inputBoxes.map { it.boundingBox }.forEach {
+ assertTrue(
+ downloadBBoxes.any { downloadBBox -> it.isCompletelyInside(downloadBBox) },
+ "input bounding box $it is not contained in any download box $downloadBBoxes"
+ )
+ }
+ assertEquals(
+ inputBoxes.sumOf { it.boundingBox.area() },
+ downloadBBoxes.sumOf { it.area() },
+ 1.0,
+ "area to download is not the same as area of input boxes"
+ )
+ }
+}