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 @@ + + + + + + + + + + + + + + + +