From 16722d2a3101615a8dcc86c268d721ac662b99dc Mon Sep 17 00:00:00 2001 From: Boris Bukin Date: Fri, 8 May 2026 11:28:55 +0300 Subject: [PATCH 01/11] issue #3, basic resolve --- .gitignore | 2 + app/build.gradle.kts | 5 +- app/src/main/AndroidManifest.xml | 5 + .../osmand/aidlapi/IOsmAndAidlInterface.aidl | 202 +++++++++ .../aidl/net/osmand/aidlapi/gpx/AGpxFile.aidl | 3 + .../osmand/aidlapi/gpx/AGpxFileDetails.aidl | 3 + .../osmand/aidlapi/gpx/ASelectedGpxFile.aidl | 3 + .../osmand/aidlapi/gpx/ImportGpxParams.aidl | 3 + .../data/repository/CorridorCalculator.kt | 119 +++++ .../data/repository/GpxTrackParser.kt | 28 ++ .../data/repository/OsmAndConnection.kt | 153 +++++++ .../data/repository/TrackCacheRepository.kt | 55 +++ .../presentation/MainActivity.kt | 18 +- .../presentation/TrackCorridorScreen.kt | 405 ++++++++++++++++++ .../java/net/osmand/aidlapi/AidlParams.java | 33 ++ .../java/net/osmand/aidlapi/gpx/AGpxFile.java | 74 ++++ .../osmand/aidlapi/gpx/AGpxFileDetails.java | 144 +++++++ .../osmand/aidlapi/gpx/ASelectedGpxFile.java | 63 +++ .../osmand/aidlapi/gpx/ImportGpxParams.java | 87 ++++ .../CorridorCalculatorTest.kt | 70 +++ .../GpxTrackParserTest.kt | 59 +++ .../TrackCacheRepositoryTest.kt | 28 ++ 22 files changed, 1558 insertions(+), 4 deletions(-) create mode 100644 app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl create mode 100644 app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxFile.aidl create mode 100644 app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxFileDetails.aidl create mode 100644 app/src/main/aidl/net/osmand/aidlapi/gpx/ASelectedGpxFile.aidl create mode 100644 app/src/main/aidl/net/osmand/aidlapi/gpx/ImportGpxParams.aidl create mode 100644 app/src/main/java/com/example/googleAttractionsGpx/data/repository/CorridorCalculator.kt create mode 100644 app/src/main/java/com/example/googleAttractionsGpx/data/repository/GpxTrackParser.kt create mode 100644 app/src/main/java/com/example/googleAttractionsGpx/data/repository/OsmAndConnection.kt create mode 100644 app/src/main/java/com/example/googleAttractionsGpx/data/repository/TrackCacheRepository.kt create mode 100644 app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt create mode 100644 app/src/main/java/net/osmand/aidlapi/AidlParams.java create mode 100644 app/src/main/java/net/osmand/aidlapi/gpx/AGpxFile.java create mode 100644 app/src/main/java/net/osmand/aidlapi/gpx/AGpxFileDetails.java create mode 100644 app/src/main/java/net/osmand/aidlapi/gpx/ASelectedGpxFile.java create mode 100644 app/src/main/java/net/osmand/aidlapi/gpx/ImportGpxParams.java create mode 100644 app/src/test/java/com/example/googleAttractionsGpx/CorridorCalculatorTest.kt create mode 100644 app/src/test/java/com/example/googleAttractionsGpx/GpxTrackParserTest.kt create mode 100644 app/src/test/java/com/example/googleAttractionsGpx/TrackCacheRepositoryTest.kt diff --git a/.gitignore b/.gitignore index 2b3093d..d6bbeb6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ local.properties app/release/ app/debug/ app/build/ +.sdd/ +build/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8759f5a..db335f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.example.googleAttractionsGpx" minSdk = 28 targetSdk = 35 - versionCode = 1 - versionName = project.findProperty("versionName")?.toString() ?: "1.0.0" + versionCode = 5 + versionName = project.findProperty("versionName")?.toString() ?: "1.4.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -46,6 +46,7 @@ android { } buildFeatures { compose = true + aidl = true } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a89c4f..fbceb4a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,4 +37,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl b/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl new file mode 100644 index 0000000..c45216c --- /dev/null +++ b/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl @@ -0,0 +1,202 @@ +package net.osmand.aidlapi; + +import net.osmand.aidlapi.gpx.ASelectedGpxFile; +import net.osmand.aidlapi.gpx.AGpxFile; +import net.osmand.aidlapi.gpx.ImportGpxParams; + +interface IOsmAndAidlInterface { + // 1 + boolean addMapMarker(in Bundle params); + // 2 + boolean removeMapMarker(in Bundle params); + // 3 + boolean updateMapMarker(in Bundle params); + // 4 + boolean addMapWidget(in Bundle params); + // 5 + boolean removeMapWidget(in Bundle params); + // 6 + boolean updateMapWidget(in Bundle params); + // 7 + boolean addMapPoint(in Bundle params); + // 8 + boolean removeMapPoint(in Bundle params); + // 9 + boolean updateMapPoint(in Bundle params); + // 10 + boolean addMapLayer(in Bundle params); + // 11 + boolean removeMapLayer(in Bundle params); + // 12 + boolean updateMapLayer(in Bundle params); + // 13 + boolean importGpx(in ImportGpxParams params); + // 14 + boolean showGpx(in Bundle params); + // 15 + boolean hideGpx(in Bundle params); + // 16 + boolean getActiveGpx(out List files); + // 17 + boolean setMapLocation(in Bundle params); + // 18 + boolean calculateRoute(in Bundle params); + // 19 + boolean refreshMap(); + // 20 + boolean addFavoriteGroup(in Bundle params); + // 21 + boolean removeFavoriteGroup(in Bundle params); + // 22 + boolean updateFavoriteGroup(in Bundle params); + // 23 + boolean addFavorite(in Bundle params); + // 24 + boolean removeFavorite(in Bundle params); + // 25 + boolean updateFavorite(in Bundle params); + // 26 + boolean startGpxRecording(in Bundle params); + // 27 + boolean stopGpxRecording(in Bundle params); + // 28 + boolean takePhotoNote(in Bundle params); + // 29 + boolean startVideoRecording(in Bundle params); + // 30 + boolean startAudioRecording(in Bundle params); + // 31 + boolean stopRecording(in Bundle params); + // 32 + boolean navigate(in Bundle params); + // 33 + boolean navigateGpx(inout Bundle params); + // 34 + boolean removeGpx(in Bundle params); + // 35 + boolean showMapPoint(in Bundle params); + // 36 + boolean setNavDrawerItems(in Bundle params); + // 37 + boolean pauseNavigation(in Bundle params); + // 38 + boolean resumeNavigation(in Bundle params); + // 39 + boolean stopNavigation(in Bundle params); + // 40 + boolean muteNavigation(in Bundle params); + // 41 + boolean unmuteNavigation(in Bundle params); + // 42 + boolean search(in Bundle params, IBinder callback); + // 43 + boolean navigateSearch(in Bundle params); + // 44 + long registerForUpdates(in long updateTimeMS, IBinder callback); + // 45 + boolean unregisterFromUpdates(in long callbackId); + // 46 + boolean setNavDrawerLogo(in String imageUri); + // 47 + boolean setEnabledIds(in List ids); + // 48 + boolean setDisabledIds(in List ids); + // 49 + boolean setEnabledPatterns(in List patterns); + // 50 + boolean setDisabledPatterns(in List patterns); + // 51 + boolean regWidgetVisibility(in Bundle params); + // 52 + boolean regWidgetAvailability(in Bundle params); + // 53 + boolean customizeOsmandSettings(in Bundle params); + // 54 + boolean getImportedGpx(out List files); + // 55 + boolean getSqliteDbFiles(out List files); + // 56 + boolean getActiveSqliteDbFiles(out List files); + // 57 + boolean showSqliteDbFile(String fileName); + // 58 + boolean hideSqliteDbFile(String fileName); + // 59 + boolean setNavDrawerLogoWithParams(in Bundle params); + // 60 + boolean setNavDrawerFooterWithParams(in Bundle params); + // 61 + boolean restoreOsmand(); + // 62 + boolean changePluginState(in Bundle params); + // 63 + boolean registerForOsmandInitListener(IBinder callback); + // 64 + boolean getBitmapForGpx(in Bundle file, IBinder callback); + // 65 + int copyFile(in Bundle filePart); + // 66 + long registerForNavigationUpdates(in Bundle params, IBinder callback); + // 67 + long addContextMenuButtons(in Bundle params, IBinder callback); + // 68 + boolean removeContextMenuButtons(in Bundle params); + // 69 + boolean updateContextMenuButtons(in Bundle params); + // 70 + boolean areOsmandSettingsCustomized(in Bundle params); + // 71 + boolean setCustomization(in Bundle params); + // 72 + long registerForVoiceRouterMessages(in Bundle params, IBinder callback); + // 73 + boolean removeAllActiveMapMarkers(in Bundle params); + // 74 + boolean importProfile(in Bundle params); + // 75 + boolean executeQuickAction(in Bundle params); + // 76 + boolean getQuickActionsInfo(out List quickActions); + // 77 + boolean setLockState(in Bundle params); + // 78 + long registerForKeyEvents(in Bundle params, IBinder callback); + // 79 + Bundle getAppInfo(); + // 80 + boolean setMapMargins(in Bundle params); + // 81 + boolean exportProfile(in Bundle params); + // 82 + boolean isFragmentOpen(); + // 83 + boolean isMenuOpen(); + // 84 + int getPluginVersion(in Bundle params); + // 85 + boolean selectProfile(in Bundle params); + // 86 + boolean getProfiles(out List profiles); + // 87 + boolean getBlockedRoads(out List blockedRoads); + // 88 + boolean addRoadBlock(in Bundle params); + // 89 + boolean removeRoadBlock(in Bundle params); + // 90 + boolean setLocation(in Bundle params); + // 91 + boolean exitApp(in Bundle params); + // 92 + boolean getText(inout Bundle params); + // 93 + boolean reloadIndexes(); + // 94 + boolean setPreference(in Bundle params); + // 95 + boolean getPreference(inout Bundle params); + // 96 + long registerForLogcatMessages(in Bundle params, IBinder callback); + // 97 + boolean setZoomLimits(in Bundle params); +} diff --git a/app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxFile.aidl b/app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxFile.aidl new file mode 100644 index 0000000..6c0e4be --- /dev/null +++ b/app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxFile.aidl @@ -0,0 +1,3 @@ +package net.osmand.aidlapi.gpx; + +parcelable AGpxFile; diff --git a/app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxFileDetails.aidl b/app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxFileDetails.aidl new file mode 100644 index 0000000..3359817 --- /dev/null +++ b/app/src/main/aidl/net/osmand/aidlapi/gpx/AGpxFileDetails.aidl @@ -0,0 +1,3 @@ +package net.osmand.aidlapi.gpx; + +parcelable AGpxFileDetails; diff --git a/app/src/main/aidl/net/osmand/aidlapi/gpx/ASelectedGpxFile.aidl b/app/src/main/aidl/net/osmand/aidlapi/gpx/ASelectedGpxFile.aidl new file mode 100644 index 0000000..860c669 --- /dev/null +++ b/app/src/main/aidl/net/osmand/aidlapi/gpx/ASelectedGpxFile.aidl @@ -0,0 +1,3 @@ +package net.osmand.aidlapi.gpx; + +parcelable ASelectedGpxFile; diff --git a/app/src/main/aidl/net/osmand/aidlapi/gpx/ImportGpxParams.aidl b/app/src/main/aidl/net/osmand/aidlapi/gpx/ImportGpxParams.aidl new file mode 100644 index 0000000..9574484 --- /dev/null +++ b/app/src/main/aidl/net/osmand/aidlapi/gpx/ImportGpxParams.aidl @@ -0,0 +1,3 @@ +package net.osmand.aidlapi.gpx; + +parcelable ImportGpxParams; diff --git a/app/src/main/java/com/example/googleAttractionsGpx/data/repository/CorridorCalculator.kt b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/CorridorCalculator.kt new file mode 100644 index 0000000..31cca81 --- /dev/null +++ b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/CorridorCalculator.kt @@ -0,0 +1,119 @@ +package com.example.googleAttractionsGpx.data.repository + +import com.example.googleAttractionsGpx.domain.models.Coordinates +import kotlin.math.* + +data class CorridorBounds( + val minLat: Double, + val maxLat: Double, + val minLng: Double, + val maxLng: Double, + val center: Coordinates, + val radiusMeters: Int, +) + +object CorridorCalculator { + + private const val EARTH_RADIUS_KM = 6371.0 + + /** Returns cumulative distances in kilometers for each point. */ + fun cumulativeDistances(points: List): List { + if (points.isEmpty()) return emptyList() + val distances = mutableListOf(0.0) + for (i in 1 until points.size) { + distances.add(distances[i - 1] + haversineKm(points[i - 1], points[i])) + } + return distances + } + + /** + * Extracts the sub-segment of points between [startKm] and [endKm]. + * Interpolates start/end points if they fall between track points. + */ + fun extractSubSegment(points: List, startKm: Double, endKm: Double): List { + require(startKm <= endKm) { "Start distance must be less than or equal to end distance" } + if (points.isEmpty()) return emptyList() + + val cumDist = cumulativeDistances(points) + val result = mutableListOf() + + for (i in points.indices) { + if (cumDist[i] >= startKm && cumDist[i] <= endKm) { + if (result.isEmpty() && i > 0 && cumDist[i - 1] < startKm) { + val ratio = (startKm - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1]) + result.add(interpolate(points[i - 1], points[i], ratio)) + } + result.add(points[i]) + } else if (cumDist[i] > endKm) { + if (i > 0 && cumDist[i - 1] <= endKm) { + val ratio = (endKm - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1]) + result.add(interpolate(points[i - 1], points[i], ratio)) + } + break + } + } + + if (result.isEmpty() && points.isNotEmpty() && startKm <= cumDist.last()) { + for (i in 1 until points.size) { + if (cumDist[i] >= startKm) { + val ratio = (startKm - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1]) + result.add(interpolate(points[i - 1], points[i], ratio)) + break + } + } + } + + return result + } + + /** Computes bounding box expanded by [widthMeters] in all directions. */ + fun computeCorridorBounds(segment: List, widthMeters: Int): CorridorBounds { + require(segment.isNotEmpty()) { "Segment must not be empty" } + + val minLat = segment.minOf { it.latitude } + val maxLat = segment.maxOf { it.latitude } + val minLng = segment.minOf { it.longitude } + val maxLng = segment.maxOf { it.longitude } + + val centerLat = (minLat + maxLat) / 2.0 + val centerLng = (minLng + maxLng) / 2.0 + + val latOffset = widthMeters / 111_320.0 + val lngOffset = widthMeters / (111_320.0 * cos(Math.toRadians(centerLat))) + + val expandedMinLat = minLat - latOffset + val expandedMaxLat = maxLat + latOffset + val expandedMinLng = minLng - lngOffset + val expandedMaxLng = maxLng + lngOffset + + val center = Coordinates(centerLat, centerLng) + + val corner = Coordinates(expandedMaxLat, expandedMaxLng) + val radiusMeters = (haversineKm(center, corner) * 1000).toInt() + + return CorridorBounds( + minLat = expandedMinLat, + maxLat = expandedMaxLat, + minLng = expandedMinLng, + maxLng = expandedMaxLng, + center = center, + radiusMeters = radiusMeters, + ) + } + + private fun haversineKm(a: Coordinates, b: Coordinates): Double { + val dLat = Math.toRadians(b.latitude - a.latitude) + val dLon = Math.toRadians(b.longitude - a.longitude) + val lat1 = Math.toRadians(a.latitude) + val lat2 = Math.toRadians(b.latitude) + val h = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2) + return 2.0 * EARTH_RADIUS_KM * asin(sqrt(h)) + } + + private fun interpolate(a: Coordinates, b: Coordinates, ratio: Double): Coordinates { + return Coordinates( + latitude = a.latitude + (b.latitude - a.latitude) * ratio, + longitude = a.longitude + (b.longitude - a.longitude) * ratio, + ) + } +} diff --git a/app/src/main/java/com/example/googleAttractionsGpx/data/repository/GpxTrackParser.kt b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/GpxTrackParser.kt new file mode 100644 index 0000000..e5e0e3e --- /dev/null +++ b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/GpxTrackParser.kt @@ -0,0 +1,28 @@ +package com.example.googleAttractionsGpx.data.repository + +import com.example.googleAttractionsGpx.domain.models.Coordinates +import java.io.InputStream +import javax.xml.parsers.SAXParserFactory +import org.xml.sax.Attributes +import org.xml.sax.helpers.DefaultHandler + +object GpxTrackParser { + + fun parseTrackPoints(input: InputStream): List { + val points = mutableListOf() + val factory = SAXParserFactory.newInstance() + val parser = factory.newSAXParser() + parser.parse(input, object : DefaultHandler() { + override fun startElement(uri: String?, localName: String?, qName: String?, attributes: Attributes?) { + if (qName == "trkpt" && attributes != null) { + val lat = attributes.getValue("lat")?.toDoubleOrNull() + val lon = attributes.getValue("lon")?.toDoubleOrNull() + if (lat != null && lon != null) { + points.add(Coordinates(lat, lon)) + } + } + } + }) + return points + } +} diff --git a/app/src/main/java/com/example/googleAttractionsGpx/data/repository/OsmAndConnection.kt b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/OsmAndConnection.kt new file mode 100644 index 0000000..e77aa72 --- /dev/null +++ b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/OsmAndConnection.kt @@ -0,0 +1,153 @@ +package com.example.googleAttractionsGpx.data.repository + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.IBinder +import android.util.Log +import androidx.core.content.FileProvider +import net.osmand.aidlapi.IOsmAndAidlInterface +import net.osmand.aidlapi.gpx.ASelectedGpxFile +import net.osmand.aidlapi.gpx.AGpxFile +import net.osmand.aidlapi.gpx.ImportGpxParams +import java.io.File + +class OsmAndConnection(private val context: Context) { + + companion object { + private const val TAG = "OsmAndConnection" + } + + private var osmAndInterface: IOsmAndAidlInterface? = null + private var boundPackage: String? = null + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + osmAndInterface = IOsmAndAidlInterface.Stub.asInterface(service) + } + + override fun onServiceDisconnected(name: ComponentName?) { + osmAndInterface = null + boundPackage = null + } + } + + fun isOsmAndInstalled(): Boolean { + val pkg = getOsmAndPackage() + Log.d(TAG, "isOsmAndInstalled: package=$pkg") + return pkg != null + } + + fun bind(onConnected: () -> Unit, onFailed: () -> Unit) { + val pkg = getOsmAndPackage() + Log.d(TAG, "bind: resolved package=$pkg") + if (pkg == null) { + Log.w(TAG, "bind: no OsmAnd package found") + onFailed() + return + } + val intent = Intent("net.osmand.aidl.OsmandAidlServiceV2") + intent.setPackage(pkg) + Log.d(TAG, "bind: binding to action=net.osmand.aidl.OsmandAidlServiceV2 package=$pkg") + val bound = context.bindService(intent, object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + Log.d(TAG, "onServiceConnected: name=$name") + osmAndInterface = IOsmAndAidlInterface.Stub.asInterface(service) + boundPackage = pkg + onConnected() + } + + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "onServiceDisconnected") + osmAndInterface = null + boundPackage = null + } + }, Context.BIND_AUTO_CREATE) + Log.d(TAG, "bind: bindService returned $bound") + if (!bound) { + Log.w(TAG, "bind: bindService failed") + onFailed() + } + } + + data class TrackResult(val tracks: List, val diagnostics: String) + + fun getActiveTracks(): TrackResult { + val iface = osmAndInterface + if (iface == null) { + return TrackResult(emptyList(), "AIDL interface is null (not bound)") + } + val files = mutableListOf() + return try { + val result = iface.getActiveGpx(files) + Log.d(TAG, "getActiveGpx returned $result, files.size=${files.size}") + val diag = "getActiveGpx: result=$result, count=${files.size}" + + files.joinToString { "\n - ${it.fileName}" } + TrackResult(files.mapNotNull { it.fileName }, diag) + } catch (e: Exception) { + Log.e(TAG, "getActiveGpx failed", e) + TrackResult(emptyList(), "getActiveGpx exception: ${e.message}") + } + } + + fun getImportedTracks(): TrackResult { + val iface = osmAndInterface + if (iface == null) { + return TrackResult(emptyList(), "AIDL interface is null (not bound)") + } + val files = mutableListOf() + return try { + val result = iface.getImportedGpx(files) + Log.d(TAG, "getImportedGpx returned $result, files.size=${files.size}") + val diag = "getImportedGpx: result=$result, count=${files.size}" + + files.joinToString { "\n - ${it.fileName} (active=${it.isActive})" } + TrackResult(files.mapNotNull { it.fileName }, diag) + } catch (e: Exception) { + Log.e(TAG, "getImportedGpx failed", e) + TrackResult(emptyList(), "getImportedGpx exception: ${e.message}") + } + } + + fun importGpx(file: File, fileName: String, show: Boolean = true): Boolean { + val iface = osmAndInterface ?: return false + val uri = FileProvider.getUriForFile( + context, "com.example.googleAttractionsGpx.fileProvider", file + ) + val pkg = boundPackage ?: return false + context.grantUriPermission(pkg, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + return try { + val params = ImportGpxParams(uri, fileName, "", show) + iface.importGpx(params) + } catch (e: Exception) { + false + } + } + + fun unbind() { + try { + context.unbindService(serviceConnection) + } catch (_: Exception) {} + osmAndInterface = null + boundPackage = null + } + + private fun getOsmAndPackage(): String? { + val pm = context.packageManager + return when { + isPackageInstalled(pm, "net.osmand.plus") -> "net.osmand.plus" + isPackageInstalled(pm, "net.osmand") -> "net.osmand" + else -> null + } + } + + private fun isPackageInstalled(pm: PackageManager, pkg: String): Boolean { + return try { + pm.getPackageInfo(pkg, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } + } +} diff --git a/app/src/main/java/com/example/googleAttractionsGpx/data/repository/TrackCacheRepository.kt b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/TrackCacheRepository.kt new file mode 100644 index 0000000..c6b33d3 --- /dev/null +++ b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/TrackCacheRepository.kt @@ -0,0 +1,55 @@ +package com.example.googleAttractionsGpx.data.repository + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit + +class TrackCacheRepository(context: Context) { + + companion object { + private const val PREFS_NAME = "TRACK_CACHE_PREFS" + private const val KEY_CACHE = "track_uri_cache" + + fun serialize(cache: Map): String { + return cache.entries.joinToString("\n") { (k, v) -> + "${encodeComponent(k)}=${encodeComponent(v)}" + } + } + + fun deserialize(raw: String?): Map { + if (raw.isNullOrBlank()) return emptyMap() + return raw.lines() + .filter { it.contains('=') } + .associate { line -> + val idx = line.indexOf('=') + decodeComponent(line.substring(0, idx)) to decodeComponent(line.substring(idx + 1)) + } + } + + private fun encodeComponent(s: String): String = + s.replace("%", "%25").replace("=", "%3D").replace("\n", "%0A") + + private fun decodeComponent(s: String): String = + s.replace("%0A", "\n").replace("%3D", "=").replace("%25", "%") + } + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun getUri(trackName: String): String? { + val cache = deserialize(prefs.getString(KEY_CACHE, null)) + return cache[trackName] + } + + fun putUri(trackName: String, uri: String) { + val cache = deserialize(prefs.getString(KEY_CACHE, null)).toMutableMap() + cache[trackName] = uri + prefs.edit { putString(KEY_CACHE, serialize(cache)) } + } + + fun removeUri(trackName: String) { + val cache = deserialize(prefs.getString(KEY_CACHE, null)).toMutableMap() + cache.remove(trackName) + prefs.edit { putString(KEY_CACHE, serialize(cache)) } + } +} diff --git a/app/src/main/java/com/example/googleAttractionsGpx/presentation/MainActivity.kt b/app/src/main/java/com/example/googleAttractionsGpx/presentation/MainActivity.kt index 176d4a6..1b4db64 100644 --- a/app/src/main/java/com/example/googleAttractionsGpx/presentation/MainActivity.kt +++ b/app/src/main/java/com/example/googleAttractionsGpx/presentation/MainActivity.kt @@ -116,7 +116,8 @@ class MainActivity : ComponentActivity() { NavHost(navController = navController, startDestination = "main") { composable("main") { GpxGeneratorScreen( - onNavigateToSettings = { navController.navigate("settings") } + onNavigateToSettings = { navController.navigate("settings") }, + onNavigateToCorridor = { navController.navigate("track-corridor") } ) } composable("settings") { @@ -128,6 +129,11 @@ class MainActivity : ComponentActivity() { composable("settings/need-photo-exclusions") { NeedPhotoExclusionsScreen(onNavigateBack = { navController.popBackStack() }) } + composable("track-corridor") { + TrackCorridorScreen( + onNavigateBack = { navController.popBackStack() } + ) + } } } } @@ -138,7 +144,7 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun GpxGeneratorScreen(onNavigateToSettings: () -> Unit = {}) { +fun GpxGeneratorScreen(onNavigateToSettings: () -> Unit = {}, onNavigateToCorridor: () -> Unit = {}) { val context = LocalContext.current val settingsRepository: SettingsRepository = remember { SettingsRepositoryImpl(context) } val scope = rememberCoroutineScope() @@ -261,6 +267,14 @@ fun GpxGeneratorScreen(onNavigateToSettings: () -> Unit = {}) { TopAppBar( title = { Text("Stuff around") }, actions = { + IconButton(onClick = onNavigateToCorridor) { + Icon( + painter = androidx.compose.ui.res.painterResource( + android.R.drawable.ic_menu_directions + ), + contentDescription = "Track Corridor" + ) + } IconButton(onClick = onNavigateToSettings) { Icon(Icons.Filled.Settings, contentDescription = "Settings") } diff --git a/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt new file mode 100644 index 0000000..a878c73 --- /dev/null +++ b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt @@ -0,0 +1,405 @@ +package com.example.googleAttractionsGpx.presentation + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import com.example.googleAttractionsGpx.data.repository.* +import com.example.googleAttractionsGpx.domain.models.Coordinates +import com.example.googleAttractionsGpx.domain.models.PointData +import com.example.googleAttractionsGpx.domain.repository.IGpxGenerator +import com.example.googleAttractionsGpx.domain.repository.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.charset.Charset + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TrackCorridorScreen(onNavigateBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val settingsRepository: SettingsRepository = remember { SettingsRepositoryImpl(context) } + val osmAnd = remember { OsmAndConnection(context) } + val trackCache = remember { TrackCacheRepository(context) } + + var tracks by remember { mutableStateOf>(emptyList()) } + var selectedTrack by remember { mutableStateOf(null) } + var trackPoints by remember { mutableStateOf>(emptyList()) } + var totalLengthKm by remember { mutableStateOf(0.0) } + + var startKm by remember { mutableStateOf("") } + var endKm by remember { mutableStateOf("") } + var widthMeters by remember { mutableStateOf("200") } + var includeTrack by remember { mutableStateOf(false) } + + var statusText by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + var generatedFile by remember { mutableStateOf(null) } + var needsFilePick by remember { mutableStateOf(false) } + + val filePickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null && selectedTrack != null) { + context.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + trackCache.putUri(selectedTrack!!, uri.toString()) + scope.launch { + loadTrackFromUri(context, uri)?.let { points -> + trackPoints = points + val dists = CorridorCalculator.cumulativeDistances(points) + totalLengthKm = dists.lastOrNull() ?: 0.0 + statusText = "Track loaded: ${points.size} points, ${"%.1f".format(totalLengthKm)} km" + } ?: run { + statusText = "Error: Selected file contains no track data" + } + } + } + needsFilePick = false + } + + LaunchedEffect(needsFilePick) { + if (needsFilePick) { + filePickerLauncher.launch(arrayOf("application/gpx+xml", "application/octet-stream", "*/*")) + } + } + + fun fetchTracks() { + if (!osmAnd.isOsmAndInstalled()) { + statusText = "OsmAnd is not installed. Please install OsmAnd or OsmAnd+." + return + } + isLoading = true + statusText = "Connecting to OsmAnd…" + osmAnd.bind( + onConnected = { + val activeResult = osmAnd.getActiveTracks() + val importedResult = if (activeResult.tracks.isEmpty()) osmAnd.getImportedTracks() else null + val allTracks = importedResult?.tracks ?: activeResult.tracks + tracks = allTracks + isLoading = false + val diag = activeResult.diagnostics + + (if (importedResult != null) "\n${importedResult.diagnostics}" else "") + statusText = if (allTracks.isEmpty()) "No active tracks found.\n$diag" + else "Found ${allTracks.size} track(s)\n$diag" + }, + onFailed = { + isLoading = false + statusText = "Failed to connect to OsmAnd service" + } + ) + } + + fun selectTrack(trackName: String) { + selectedTrack = trackName + val cachedUri = trackCache.getUri(trackName) + if (cachedUri != null) { + scope.launch { + val uri = Uri.parse(cachedUri) + val points = loadTrackFromUri(context, uri) + if (points != null && points.isNotEmpty()) { + trackPoints = points + val dists = CorridorCalculator.cumulativeDistances(points) + totalLengthKm = dists.lastOrNull() ?: 0.0 + statusText = "Track loaded: ${points.size} points, ${"%.1f".format(totalLengthKm)} km" + } else { + trackCache.removeUri(trackName) + statusText = "Cached file not accessible. Please select the GPX file." + needsFilePick = true + } + } + } else { + statusText = "Please select the GPX file for \"$trackName\"" + needsFilePick = true + } + } + + fun generate() { + val start = startKm.toDoubleOrNull() + val end = endKm.toDoubleOrNull() + val width = widthMeters.toIntOrNull() + + if (start == null || end == null || width == null) { + statusText = "Please enter valid numbers for all fields"; return + } + if (start >= end) { statusText = "Start distance must be less than end distance"; return } + if (end > totalLengthKm) { + statusText = "End distance exceeds track length (${"%.1f".format(totalLengthKm)} km)"; return + } + if (width <= 0) { statusText = "Corridor width must be a positive number"; return } + + val sources = settingsRepository.selectedSources.toList() + if (sources.isEmpty()) { statusText = "Enable at least one data source in Settings"; return } + + isLoading = true + statusText = "Calculating corridor…" + + scope.launch { + try { + val segment = withContext(Dispatchers.Default) { + CorridorCalculator.extractSubSegment(trackPoints, start, end) + } + if (segment.isEmpty()) { + statusText = "Could not extract track segment"; isLoading = false; return@launch + } + + val bounds = CorridorCalculator.computeCorridorBounds(segment, width) + statusText = "Finding POIs in corridor (radius ${bounds.radiusMeters}m)…" + + val allPoints = mutableListOf() + val errors = mutableListOf() + val sourceColors = settingsRepository.sourceColors + + val deferreds = sources.map { id -> + async(Dispatchers.IO) { + val gen = buildCorridorGenerator(id, settingsRepository, context) + if (gen == null) Pair(id, emptyList()) + else try { + Pair(id, gen.getData(bounds.center, bounds.radiusMeters)) + } catch (e: Exception) { + synchronized(errors) { errors.add("$id: ${e.message}") } + Pair(id, emptyList()) + } + } + } + val results = deferreds.map { it.await() } + results.forEach { (id, pts) -> + val color = sourceColors[id] + val filtered = pts.filter { p -> + p.coordinates.latitude in bounds.minLat..bounds.maxLat && + p.coordinates.longitude in bounds.minLng..bounds.maxLng + } + allPoints.addAll(filtered.map { it.copy(color = color) }) + } + + val gpxContent = if (includeTrack) { + buildCorridorGpxContent(allPoints, segment) + } else { + buildGpxContent(allPoints) + } + + val fileName = "corridor_${selectedTrack}_${"%.0f".format(start)}-${"%.0f".format(end)}km.gpx" + .replace("/", "_").replace("\\", "_") + val file = File(context.getExternalFilesDir(null), fileName) + withContext(Dispatchers.IO) { file.writeText(gpxContent, Charset.defaultCharset()) } + generatedFile = file + + val stats = results.joinToString(", ") { (id, pts) -> "$id: ${pts.size}" } + val filteredCount = allPoints.size + val errStr = if (errors.isNotEmpty()) "\nErrors: ${errors.joinToString("; ")}" else "" + statusText = if (filteredCount == 0) { + "No POIs found in the selected corridor$errStr" + } else { + "Done! $filteredCount POIs in corridor. $stats$errStr" + } + isLoading = false + } catch (e: Exception) { + statusText = "Error: ${e.message}" + isLoading = false + } + } + } + + fun sendToOsmAnd() { + val file = generatedFile ?: return + val success = osmAnd.importGpx(file, file.name, show = true) + statusText = if (success) "GPX sent to OsmAnd!" else "Failed to send to OsmAnd. Try sharing instead." + } + + fun shareFile() { + val file = generatedFile ?: return + val uri = FileProvider.getUriForFile(context, "com.example.googleAttractionsGpx.fileProvider", file) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/octet-stream") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Open GPX")) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Track Corridor") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { fetchTracks() }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { Text("Get Tracks from OsmAnd") } + + if (tracks.isNotEmpty()) { + Text("Select a track:", style = MaterialTheme.typography.titleSmall) + tracks.forEach { track -> + val name = track.substringAfterLast("/").removeSuffix(".gpx") + Surface( + onClick = { selectTrack(track) }, + shape = RoundedCornerShape(8.dp), + color = if (track == selectedTrack) + MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth() + ) { + Text( + name, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + if (trackPoints.isNotEmpty()) { + HorizontalDivider() + Text( + "Track: ${"%.1f".format(totalLengthKm)} km, ${trackPoints.size} points", + style = MaterialTheme.typography.titleSmall + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = startKm, + onValueChange = { startKm = it }, + label = { Text("Start (km)") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + OutlinedTextField( + value = endKm, + onValueChange = { endKm = it }, + label = { Text("End (km)") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + } + OutlinedTextField( + value = widthMeters, + onValueChange = { widthMeters = it }, + label = { Text("Corridor width (m)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = includeTrack, onCheckedChange = { includeTrack = it }) + Text("Include corridor track in result GPX") + } + Button( + onClick = { generate() }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { Text("Generate POIs in Corridor") } + } + + if (generatedFile != null) { + HorizontalDivider() + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { sendToOsmAnd() }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) { Text("Send to OsmAnd") } + OutlinedButton( + onClick = { shareFile() }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) { Text("Share") } + } + } + + if (statusText.isNotEmpty()) { + Text( + statusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +private suspend fun loadTrackFromUri(context: android.content.Context, uri: Uri): List? { + return withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(uri)?.use { input -> + GpxTrackParser.parseTrackPoints(input) + } + } catch (e: Exception) { + null + } + } +} + +private fun buildCorridorGenerator(id: String, settings: SettingsRepository, context: android.content.Context): IGpxGenerator? = when (id) { + "google" -> GooglePlaceGpxGenerator(settings.googleApiKey) + "osm" -> OsmPlaceGpxGenerator() + "trip" -> TripAdvisorGpxGenerator(settings.tripAdvisorApiKey) + "wikidata" -> WikidataAttractionsGpxGenerator() + "inat" -> INaturalistGpxGenerator(settings.iNaturalistUsername, context) + "wiki" -> WikipediaArticlesGpxGenerator() + "nophoto" -> NeedPhotoWikidataGpxGenerator(settings.needPhotoExclusions) + else -> null +} + +fun buildCorridorGpxContent(points: List, segment: List): String { + val sb = StringBuilder() + sb.append("""""").append("\n") + sb.append("""""") + .append("\n") + + points.forEach { point -> + sb.append(""" """).append("\n") + val escapedName = point.name.replace("&", "&").replace("<", "<").replace(">", ">") + sb.append(""" $escapedName""").append("\n") + val escapedDescription = point.description.replace("&", "&").replace("<", "<").replace(">", ">") + sb.append(""" $escapedDescription""").append("\n") + if (point.color != null) { + sb.append(""" """).append("\n") + sb.append(""" ${point.color}""").append("\n") + sb.append(""" """).append("\n") + } + sb.append(""" """).append("\n") + } + + sb.append(" \n") + sb.append(" Corridor segment\n") + sb.append(" \n") + segment.forEach { coord -> + sb.append(""" """).append("\n") + } + sb.append(" \n") + sb.append(" \n") + sb.append("\n") + return sb.toString() +} diff --git a/app/src/main/java/net/osmand/aidlapi/AidlParams.java b/app/src/main/java/net/osmand/aidlapi/AidlParams.java new file mode 100644 index 0000000..3d0cf04 --- /dev/null +++ b/app/src/main/java/net/osmand/aidlapi/AidlParams.java @@ -0,0 +1,33 @@ +package net.osmand.aidlapi; + +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +public abstract class AidlParams implements Parcelable { + + @Override + public final void writeToParcel(Parcel dest, int flags) { + Bundle bundle = new Bundle(); + writeToBundle(bundle); + dest.writeBundle(bundle); + } + + public final void readFromParcel(Parcel in) { + Bundle bundle = in.readBundle(getClass().getClassLoader()); + if (bundle != null) { + readFromBundle(bundle); + } + } + + protected void writeToBundle(Bundle bundle) { + } + + protected void readFromBundle(Bundle bundle) { + } + + @Override + public int describeContents() { + return 0; + } +} diff --git a/app/src/main/java/net/osmand/aidlapi/gpx/AGpxFile.java b/app/src/main/java/net/osmand/aidlapi/gpx/AGpxFile.java new file mode 100644 index 0000000..785b01c --- /dev/null +++ b/app/src/main/java/net/osmand/aidlapi/gpx/AGpxFile.java @@ -0,0 +1,74 @@ +package net.osmand.aidlapi.gpx; + +import android.os.Bundle; +import android.os.Parcel; + +import net.osmand.aidlapi.AidlParams; + +public class AGpxFile extends AidlParams { + + private String fileName; + private String relativePath; + private long modifiedTime; + private long fileSize; + private boolean active; + private String color; + private AGpxFileDetails details; + + public AGpxFile(String fileName, long modifiedTime, long fileSize, boolean active, String color, AGpxFileDetails details) { + this.fileName = fileName; + this.modifiedTime = modifiedTime; + this.fileSize = fileSize; + this.active = active; + this.color = color; + this.details = details; + } + + public AGpxFile(Parcel in) { + readFromParcel(in); + } + + public static final Creator CREATOR = new Creator() { + @Override + public AGpxFile createFromParcel(Parcel in) { + return new AGpxFile(in); + } + + @Override + public AGpxFile[] newArray(int size) { + return new AGpxFile[size]; + } + }; + + public String getFileName() { return fileName; } + public String getRelativePath() { return relativePath; } + public void setRelativePath(String relativePath) { this.relativePath = relativePath; } + public long getModifiedTime() { return modifiedTime; } + public long getFileSize() { return fileSize; } + public boolean isActive() { return active; } + public String getColor() { return color; } + public AGpxFileDetails getDetails() { return details; } + + @Override + public void writeToBundle(Bundle bundle) { + bundle.putString("fileName", fileName); + bundle.putString("relativePath", relativePath); + bundle.putLong("modifiedTime", modifiedTime); + bundle.putLong("fileSize", fileSize); + bundle.putBoolean("active", active); + bundle.putParcelable("details", details); + bundle.putString("color", color); + } + + @Override + protected void readFromBundle(Bundle bundle) { + bundle.setClassLoader(AGpxFileDetails.class.getClassLoader()); + fileName = bundle.getString("fileName"); + relativePath = bundle.getString("relativePath"); + modifiedTime = bundle.getLong("modifiedTime"); + fileSize = bundle.getLong("fileSize"); + active = bundle.getBoolean("active"); + details = bundle.getParcelable("details"); + color = bundle.getString("color"); + } +} diff --git a/app/src/main/java/net/osmand/aidlapi/gpx/AGpxFileDetails.java b/app/src/main/java/net/osmand/aidlapi/gpx/AGpxFileDetails.java new file mode 100644 index 0000000..9800a57 --- /dev/null +++ b/app/src/main/java/net/osmand/aidlapi/gpx/AGpxFileDetails.java @@ -0,0 +1,144 @@ +package net.osmand.aidlapi.gpx; + +import android.os.Bundle; +import android.os.Parcel; + +import net.osmand.aidlapi.AidlParams; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public class AGpxFileDetails extends AidlParams { + + private float totalDistance; + private int totalTracks; + private long startTime = Long.MAX_VALUE; + private long endTime = Long.MIN_VALUE; + private long timeSpan; + private long timeMoving; + private float totalDistanceMoving; + + private double diffElevationUp; + private double diffElevationDown; + private double avgElevation; + private double minElevation = 99999; + private double maxElevation = -100; + + private float minSpeed = Float.MAX_VALUE; + private float maxSpeed; + private float avgSpeed; + + private int points; + private int wptPoints; + + private ArrayList wptCategoryNames = new ArrayList<>(); + + public AGpxFileDetails(float totalDistance, int totalTracks, + long startTime, long endTime, + long timeSpan, long timeMoving, float totalDistanceMoving, + double diffElevationUp, double diffElevationDown, + double avgElevation, double minElevation, double maxElevation, + float minSpeed, float maxSpeed, float avgSpeed, + int points, int wptPoints, Set wptCategoryNames) { + this.totalDistance = totalDistance; + this.totalTracks = totalTracks; + this.startTime = startTime; + this.endTime = endTime; + this.timeSpan = timeSpan; + this.timeMoving = timeMoving; + this.totalDistanceMoving = totalDistanceMoving; + this.diffElevationUp = diffElevationUp; + this.diffElevationDown = diffElevationDown; + this.avgElevation = avgElevation; + this.minElevation = minElevation; + this.maxElevation = maxElevation; + this.minSpeed = minSpeed; + this.maxSpeed = maxSpeed; + this.avgSpeed = avgSpeed; + this.points = points; + this.wptPoints = wptPoints; + if (wptCategoryNames != null) { + this.wptCategoryNames.addAll(wptCategoryNames); + } + } + + public AGpxFileDetails(Parcel in) { + readFromParcel(in); + } + + public static final Creator CREATOR = new Creator() { + @Override + public AGpxFileDetails createFromParcel(Parcel in) { + return new AGpxFileDetails(in); + } + + @Override + public AGpxFileDetails[] newArray(int size) { + return new AGpxFileDetails[size]; + } + }; + + public float getTotalDistance() { return totalDistance; } + public int getTotalTracks() { return totalTracks; } + public long getStartTime() { return startTime; } + public long getEndTime() { return endTime; } + public long getTimeSpan() { return timeSpan; } + public long getTimeMoving() { return timeMoving; } + public float getTotalDistanceMoving() { return totalDistanceMoving; } + public double getDiffElevationUp() { return diffElevationUp; } + public double getDiffElevationDown() { return diffElevationDown; } + public double getAvgElevation() { return avgElevation; } + public double getMinElevation() { return minElevation; } + public double getMaxElevation() { return maxElevation; } + public float getMinSpeed() { return minSpeed; } + public float getMaxSpeed() { return maxSpeed; } + public float getAvgSpeed() { return avgSpeed; } + public int getPoints() { return points; } + public int getWptPoints() { return wptPoints; } + public List getWptCategoryNames() { return wptCategoryNames; } + + @Override + public void writeToBundle(Bundle bundle) { + bundle.putFloat("totalDistance", totalDistance); + bundle.putInt("totalTracks", totalTracks); + bundle.putLong("startTime", startTime); + bundle.putLong("endTime", endTime); + bundle.putLong("timeSpan", timeSpan); + bundle.putLong("timeMoving", timeMoving); + bundle.putFloat("totalDistanceMoving", totalDistanceMoving); + bundle.putDouble("diffElevationUp", diffElevationUp); + bundle.putDouble("diffElevationDown", diffElevationDown); + bundle.putDouble("avgElevation", avgElevation); + bundle.putDouble("minElevation", minElevation); + bundle.putDouble("maxElevation", maxElevation); + bundle.putFloat("minSpeed", minSpeed); + bundle.putFloat("maxSpeed", maxSpeed); + bundle.putFloat("avgSpeed", avgSpeed); + bundle.putInt("points", points); + bundle.putInt("wptPoints", wptPoints); + bundle.putStringArrayList("wptCategoryNames", wptCategoryNames); + } + + @Override + protected void readFromBundle(Bundle bundle) { + totalDistance = bundle.getFloat("totalDistance"); + totalTracks = bundle.getInt("totalTracks"); + startTime = bundle.getLong("startTime"); + endTime = bundle.getLong("endTime"); + timeSpan = bundle.getLong("timeSpan"); + timeMoving = bundle.getLong("timeMoving"); + totalDistanceMoving = bundle.getFloat("totalDistanceMoving"); + diffElevationUp = bundle.getDouble("diffElevationUp"); + diffElevationDown = bundle.getDouble("diffElevationDown"); + avgElevation = bundle.getDouble("avgElevation"); + minElevation = bundle.getDouble("minElevation"); + maxElevation = bundle.getDouble("maxElevation"); + minSpeed = bundle.getFloat("minSpeed"); + maxSpeed = bundle.getFloat("maxSpeed"); + avgSpeed = bundle.getFloat("avgSpeed"); + points = bundle.getInt("points"); + wptPoints = bundle.getInt("wptPoints"); + wptCategoryNames = bundle.getStringArrayList("wptCategoryNames"); + } +} diff --git a/app/src/main/java/net/osmand/aidlapi/gpx/ASelectedGpxFile.java b/app/src/main/java/net/osmand/aidlapi/gpx/ASelectedGpxFile.java new file mode 100644 index 0000000..6fc7d53 --- /dev/null +++ b/app/src/main/java/net/osmand/aidlapi/gpx/ASelectedGpxFile.java @@ -0,0 +1,63 @@ +package net.osmand.aidlapi.gpx; + +import android.os.Bundle; +import android.os.Parcel; + +import net.osmand.aidlapi.AidlParams; + +public class ASelectedGpxFile extends AidlParams { + + private String fileName; + private long modifiedTime; + private long fileSize; + private AGpxFileDetails details; + + public ASelectedGpxFile(String fileName) { + this.fileName = fileName; + } + + public ASelectedGpxFile(String fileName, long modifiedTime, long fileSize, AGpxFileDetails details) { + this.fileName = fileName; + this.modifiedTime = modifiedTime; + this.fileSize = fileSize; + this.details = details; + } + + public ASelectedGpxFile(Parcel in) { + readFromParcel(in); + } + + public static final Creator CREATOR = new Creator() { + @Override + public ASelectedGpxFile createFromParcel(Parcel in) { + return new ASelectedGpxFile(in); + } + + @Override + public ASelectedGpxFile[] newArray(int size) { + return new ASelectedGpxFile[size]; + } + }; + + public String getFileName() { return fileName; } + public long getModifiedTime() { return modifiedTime; } + public long getFileSize() { return fileSize; } + public AGpxFileDetails getDetails() { return details; } + + @Override + public void writeToBundle(Bundle bundle) { + bundle.putString("fileName", fileName); + bundle.putLong("modifiedTime", modifiedTime); + bundle.putLong("fileSize", fileSize); + bundle.putParcelable("details", details); + } + + @Override + protected void readFromBundle(Bundle bundle) { + bundle.setClassLoader(AGpxFileDetails.class.getClassLoader()); + fileName = bundle.getString("fileName"); + modifiedTime = bundle.getLong("modifiedTime"); + fileSize = bundle.getLong("fileSize"); + details = bundle.getParcelable("details"); + } +} diff --git a/app/src/main/java/net/osmand/aidlapi/gpx/ImportGpxParams.java b/app/src/main/java/net/osmand/aidlapi/gpx/ImportGpxParams.java new file mode 100644 index 0000000..dcdd8e7 --- /dev/null +++ b/app/src/main/java/net/osmand/aidlapi/gpx/ImportGpxParams.java @@ -0,0 +1,87 @@ +package net.osmand.aidlapi.gpx; + +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcel; + +import net.osmand.aidlapi.AidlParams; + +import java.io.File; + +public class ImportGpxParams extends AidlParams { + + private File gpxFile; + private Uri gpxUri; + private String sourceRawData; + private String destinationPath; + private String color; + private boolean show; + + public ImportGpxParams(File gpxFile, String destinationPath, String color, boolean show) { + this.gpxFile = gpxFile; + this.destinationPath = destinationPath; + this.color = color; + this.show = show; + } + + public ImportGpxParams(Uri gpxUri, String destinationPath, String color, boolean show) { + this.gpxUri = gpxUri; + this.destinationPath = destinationPath; + this.color = color; + this.show = show; + } + + public ImportGpxParams(String sourceRawData, String destinationPath, String color, boolean show) { + this.sourceRawData = sourceRawData; + this.destinationPath = destinationPath; + this.color = color; + this.show = show; + } + + public ImportGpxParams(Parcel in) { + readFromParcel(in); + } + + public static final Creator CREATOR = new Creator() { + @Override + public ImportGpxParams createFromParcel(Parcel in) { + return new ImportGpxParams(in); + } + + @Override + public ImportGpxParams[] newArray(int size) { + return new ImportGpxParams[size]; + } + }; + + public File getGpxFile() { return gpxFile; } + public Uri getGpxUri() { return gpxUri; } + public String getSourceRawData() { return sourceRawData; } + public String getDestinationPath() { return destinationPath; } + public String getColor() { return color; } + public boolean isShow() { return show; } + + @Override + protected void readFromBundle(Bundle bundle) { + bundle.setClassLoader(Uri.class.getClassLoader()); + String gpxAbsolutePath = bundle.getString("gpxAbsolutePath"); + if (gpxAbsolutePath != null) { + gpxFile = new File(gpxAbsolutePath); + } + gpxUri = bundle.getParcelable("gpxUri"); + sourceRawData = bundle.getString("sourceRawData"); + destinationPath = bundle.getString("destinationPath"); + color = bundle.getString("color"); + show = bundle.getBoolean("show"); + } + + @Override + public void writeToBundle(Bundle bundle) { + bundle.putString("gpxAbsolutePath", gpxFile != null ? gpxFile.getAbsolutePath() : null); + bundle.putParcelable("gpxUri", gpxUri); + bundle.putString("sourceRawData", sourceRawData); + bundle.putString("destinationPath", destinationPath); + bundle.putString("color", color); + bundle.putBoolean("show", show); + } +} diff --git a/app/src/test/java/com/example/googleAttractionsGpx/CorridorCalculatorTest.kt b/app/src/test/java/com/example/googleAttractionsGpx/CorridorCalculatorTest.kt new file mode 100644 index 0000000..15ed69d --- /dev/null +++ b/app/src/test/java/com/example/googleAttractionsGpx/CorridorCalculatorTest.kt @@ -0,0 +1,70 @@ +package com.example.googleAttractionsGpx + +import com.example.googleAttractionsGpx.data.repository.CorridorCalculator +import com.example.googleAttractionsGpx.domain.models.Coordinates +import org.junit.Assert.* +import org.junit.Test + +class CorridorCalculatorTest { + + private val points = listOf( + Coordinates(48.000, 11.000), + Coordinates(48.001, 11.000), + Coordinates(48.002, 11.000), + Coordinates(48.003, 11.000), + Coordinates(48.004, 11.000), + ) + + @Test + fun cumulativeDistances_computedCorrectly() { + val distances = CorridorCalculator.cumulativeDistances(points) + assertEquals(5, distances.size) + assertEquals(0.0, distances[0], 0.001) + assertTrue(distances[1] > 0.1 && distances[1] < 0.12) + assertTrue(distances[4] > 0.4 && distances[4] < 0.5) + } + + @Test + fun extractSubSegment_fullRange() { + val sub = CorridorCalculator.extractSubSegment(points, 0.0, 10.0) + assertEquals(5, sub.size) + } + + @Test + fun extractSubSegment_middleRange() { + val sub = CorridorCalculator.extractSubSegment(points, 0.1, 0.35) + assertTrue("Expected at least 2 points, got ${sub.size}", sub.size >= 2) + // First point should be interpolated near 48.0009 or be point[1] at 48.001 + assertTrue("First point lat=${sub.first().latitude}", sub.first().latitude >= 48.0008) + } + + @Test + fun computeBounds_expandsByWidth() { + val segment = listOf( + Coordinates(48.000, 11.000), + Coordinates(48.001, 11.000), + ) + val bounds = CorridorCalculator.computeCorridorBounds(segment, 1000) + assertTrue(bounds.minLat < 48.000) + assertTrue(bounds.maxLat > 48.001) + assertTrue(bounds.minLng < 11.000) + assertTrue(bounds.maxLng > 11.000) + } + + @Test + fun computeBounds_centerAndRadius() { + val segment = listOf( + Coordinates(48.000, 11.000), + Coordinates(48.002, 11.000), + ) + val bounds = CorridorCalculator.computeCorridorBounds(segment, 200) + assertEquals(48.001, bounds.center.latitude, 0.001) + assertEquals(11.000, bounds.center.longitude, 0.001) + assertTrue(bounds.radiusMeters > 0) + } + + @Test(expected = IllegalArgumentException::class) + fun extractSubSegment_startAfterEnd_throws() { + CorridorCalculator.extractSubSegment(points, 0.3, 0.1) + } +} diff --git a/app/src/test/java/com/example/googleAttractionsGpx/GpxTrackParserTest.kt b/app/src/test/java/com/example/googleAttractionsGpx/GpxTrackParserTest.kt new file mode 100644 index 0000000..ce88bee --- /dev/null +++ b/app/src/test/java/com/example/googleAttractionsGpx/GpxTrackParserTest.kt @@ -0,0 +1,59 @@ +package com.example.googleAttractionsGpx + +import com.example.googleAttractionsGpx.data.repository.GpxTrackParser +import org.junit.Assert.* +import org.junit.Test + +class GpxTrackParserTest { + + private val sampleGpx = """ + + + + Test Track + + + + + + + + """.trimIndent() + + @Test + fun parseTrackPoints_returnsList() { + val points = GpxTrackParser.parseTrackPoints(sampleGpx.byteInputStream()) + assertEquals(3, points.size) + assertEquals(48.0, points[0].latitude, 0.0001) + assertEquals(11.0, points[0].longitude, 0.0001) + assertEquals(48.002, points[2].latitude, 0.0001) + } + + @Test + fun parseTrackPoints_multipleSegments_concatenated() { + val gpx = """ + + + + + + + + + """.trimIndent() + val points = GpxTrackParser.parseTrackPoints(gpx.byteInputStream()) + assertEquals(2, points.size) + } + + @Test + fun parseTrackPoints_noTrackPoints_returnsEmpty() { + val gpx = """ + + + POI + + """.trimIndent() + val points = GpxTrackParser.parseTrackPoints(gpx.byteInputStream()) + assertTrue(points.isEmpty()) + } +} diff --git a/app/src/test/java/com/example/googleAttractionsGpx/TrackCacheRepositoryTest.kt b/app/src/test/java/com/example/googleAttractionsGpx/TrackCacheRepositoryTest.kt new file mode 100644 index 0000000..8a3c45a --- /dev/null +++ b/app/src/test/java/com/example/googleAttractionsGpx/TrackCacheRepositoryTest.kt @@ -0,0 +1,28 @@ +package com.example.googleAttractionsGpx + +import com.example.googleAttractionsGpx.data.repository.TrackCacheRepository +import org.junit.Assert.* +import org.junit.Test + +class TrackCacheRepositoryTest { + + @Test + fun serializeDeserialize_roundTrip() { + val cache = mutableMapOf("track1" to "content://file1", "track2" to "content://file2") + val serialized = TrackCacheRepository.serialize(cache) + val deserialized = TrackCacheRepository.deserialize(serialized) + assertEquals(cache, deserialized) + } + + @Test + fun deserialize_null_returnsEmptyMap() { + val result = TrackCacheRepository.deserialize(null) + assertTrue(result.isEmpty()) + } + + @Test + fun deserialize_empty_returnsEmptyMap() { + val result = TrackCacheRepository.deserialize("") + assertTrue(result.isEmpty()) + } +} From 87b63cc2668f6c8fcb76bbe2f67e40c4e25d1837 Mon Sep 17 00:00:00 2001 From: Boris Bukin Date: Fri, 8 May 2026 11:31:52 +0300 Subject: [PATCH 02/11] issue #3, add readme --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 020bbdb..dd3320e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ An Android app that generates GPX files with points of interest around given coo | **iNaturalist** | Species not yet observed by the user within ±15 days of today | Not required | | **Need a photo** | Wikidata objects without photos, useful for photo-mapping routes | Not required | +### Track Corridor (OsmAnd integration) + +Discover POIs along a section of a GPX track loaded in OsmAnd. The app connects to OsmAnd via AIDL, lets you pick an active track, define a start/end distance (km) and corridor width (m), then runs existing POI sources inside that corridor. The resulting GPX can be sent back to OsmAnd or shared. + +**Requirements**: OsmAnd or OsmAnd+ installed on the device. + ## Requirements - Android 9 (API 28) or higher @@ -51,19 +57,30 @@ Default exclusions for **Need a photo**: ## Usage +### POI generation by coordinates + 1. Enter coordinates in the `lat,lng` field, tap **Current**, or pick a point on the map. 2. Select one or more data sources. 3. Optionally tap the **color dot** next to any source to change its color in the palette — the chosen color is saved and applied to all waypoints from that source in the output file. 4. After generation the app will prompt you to open the file in an external app (e.g. OsmAnd, Locus Map). +### Track Corridor (OsmAnd) + +1. Tap the **route icon** (↗) in the toolbar to open the Track Corridor screen. +2. Tap **Get Tracks from OsmAnd** — the app connects to OsmAnd and lists active/imported tracks. +3. Select a track. If it's the first time, you'll be asked to pick the corresponding GPX file from device storage (the mapping is cached for next time). +4. Enter **Start distance** (km), **End distance** (km), and **Corridor width** (m). +5. Tap **Generate** — the app extracts the track segment, computes a bounding box expanded by the corridor width, and runs POI discovery using your enabled sources. +6. After generation, tap **Send to OsmAnd** to import the result GPX directly, or **Share** to send it via the standard Android chooser. + Generated files are saved to `Android/data/com.example.googleAttractionsGpx/files/`. ## Architecture ``` -presentation/ — Compose UI (MainActivity, SettingsScreen) +presentation/ — Compose UI (MainActivity, SettingsScreen, TrackCorridorScreen) domain/ — models (PointData, Coordinates) and interfaces (IGpxGenerator, SettingsRepository) -data/repository — generator implementations and SettingsRepositoryImpl +data/repository — generator implementations, SettingsRepositoryImpl, OsmAndConnection, CorridorCalculator, GpxTrackParser, TrackCacheRepository ``` ## Build From 9e47f85e75a0d78c7bd77b4c4c43d6e58afecb59 Mon Sep 17 00:00:00 2001 From: Boris Bukin Date: Fri, 8 May 2026 11:37:08 +0300 Subject: [PATCH 03/11] issue #3, add readme 2 --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dd3320e..c8e500b 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,60 @@ Generated files are saved to `Android/data/com.example.googleAttractionsGpx/file ## Architecture ``` -presentation/ — Compose UI (MainActivity, SettingsScreen, TrackCorridorScreen) -domain/ — models (PointData, Coordinates) and interfaces (IGpxGenerator, SettingsRepository) -data/repository — generator implementations, SettingsRepositoryImpl, OsmAndConnection, CorridorCalculator, GpxTrackParser, TrackCacheRepository +presentation/ — Compose UI + MainActivity.kt — main screen with coordinate input and POI generation + SettingsScreen.kt — API keys and source configuration + MapPickerSheet.kt — bottom-sheet map for picking coordinates + TrackCorridorScreen.kt — Track Corridor workflow (OsmAnd integration) + theme/ — Material 3 theme (Color, Theme, Type) + +domain/ + models/ — PointData, Coordinates + repository/ — SettingsRepository interface + +data/repository/ — implementations + SettingsRepositoryImpl.kt — SharedPreferences-backed settings + AllAttractionsGenerator.kt — orchestrates all POI sources + GooglePlaceGpxGenerator.kt — Google Places API + OsmPlaceGpxGenerator.kt — OpenStreetMap / Overpass + TripAdvisorGpxGenerator.kt — TripAdvisor Content API + WikidataAttractionsGpxGenerator.kt — Wikidata SPARQL + WikipediaArticlesGpxGenerator.kt — Wikipedia geo-search + INaturalistGpxGenerator.kt — iNaturalist species + NeedPhotoWikidataGpxGenerator.kt — Wikidata objects without photos + NeedPhotoSettings.kt — exclusion categories for photo routes + GpxGeneratorBase.kt — shared GPX-writing logic + IGpxGenerator.kt — generator interface + NominatimService.kt — reverse geocoding + OsmAndConnection.kt — AIDL binding to OsmAnd + GpxTrackParser.kt — SAX-based GPX track parser + CorridorCalculator.kt — haversine distance, sub-segment extraction, bounding box + TrackCacheRepository.kt — track-name → file-URI cache +``` + +### Track Corridor workflow + +```mermaid +flowchart TD + A[Open Track Corridor screen] --> B{OsmAnd installed?} + B -- No --> B1[Show 'Install OsmAnd' message] + B -- Yes --> C[Bind to OsmAnd via AIDL] + C --> D[Fetch active & imported tracks] + D --> E{Tracks found?} + E -- No --> E1[Show 'No tracks found'] + E -- Yes --> F[User selects a track] + F --> G{GPX file cached?} + G -- Yes --> H[Load GPX from cached URI] + G -- No --> I[User picks GPX file] + I --> H + H --> J[User enters start/end km + corridor width m] + J --> K[Extract track sub-segment] + K --> L[Compute corridor bounding box] + L --> M[Run POI discovery with enabled sources] + M --> N[Generate result GPX] + N --> O{User choice} + O --> P[Send to OsmAnd via AIDL] + O --> Q[Share via Android chooser] ``` ## Build From 80051108569013c56d66a2a0d880e66ba97df7eb Mon Sep 17 00:00:00 2001 From: Boris Bukin Date: Fri, 8 May 2026 11:41:28 +0300 Subject: [PATCH 04/11] issue #3, add readme 3 --- README.md | 57 ++------------------------------- docs/architecture.md | 33 +++++++++++++++++++ docs/track-corridor-workflow.md | 24 ++++++++++++++ 3 files changed, 59 insertions(+), 55 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/track-corridor-workflow.md diff --git a/README.md b/README.md index c8e500b..9351c1d 100644 --- a/README.md +++ b/README.md @@ -77,62 +77,9 @@ Generated files are saved to `Android/data/com.example.googleAttractionsGpx/file ## Architecture -``` -presentation/ — Compose UI - MainActivity.kt — main screen with coordinate input and POI generation - SettingsScreen.kt — API keys and source configuration - MapPickerSheet.kt — bottom-sheet map for picking coordinates - TrackCorridorScreen.kt — Track Corridor workflow (OsmAnd integration) - theme/ — Material 3 theme (Color, Theme, Type) - -domain/ - models/ — PointData, Coordinates - repository/ — SettingsRepository interface - -data/repository/ — implementations - SettingsRepositoryImpl.kt — SharedPreferences-backed settings - AllAttractionsGenerator.kt — orchestrates all POI sources - GooglePlaceGpxGenerator.kt — Google Places API - OsmPlaceGpxGenerator.kt — OpenStreetMap / Overpass - TripAdvisorGpxGenerator.kt — TripAdvisor Content API - WikidataAttractionsGpxGenerator.kt — Wikidata SPARQL - WikipediaArticlesGpxGenerator.kt — Wikipedia geo-search - INaturalistGpxGenerator.kt — iNaturalist species - NeedPhotoWikidataGpxGenerator.kt — Wikidata objects without photos - NeedPhotoSettings.kt — exclusion categories for photo routes - GpxGeneratorBase.kt — shared GPX-writing logic - IGpxGenerator.kt — generator interface - NominatimService.kt — reverse geocoding - OsmAndConnection.kt — AIDL binding to OsmAnd - GpxTrackParser.kt — SAX-based GPX track parser - CorridorCalculator.kt — haversine distance, sub-segment extraction, bounding box - TrackCacheRepository.kt — track-name → file-URI cache -``` +See [docs/architecture.md](docs/architecture.md) for the full project structure. -### Track Corridor workflow - -```mermaid -flowchart TD - A[Open Track Corridor screen] --> B{OsmAnd installed?} - B -- No --> B1[Show 'Install OsmAnd' message] - B -- Yes --> C[Bind to OsmAnd via AIDL] - C --> D[Fetch active & imported tracks] - D --> E{Tracks found?} - E -- No --> E1[Show 'No tracks found'] - E -- Yes --> F[User selects a track] - F --> G{GPX file cached?} - G -- Yes --> H[Load GPX from cached URI] - G -- No --> I[User picks GPX file] - I --> H - H --> J[User enters start/end km + corridor width m] - J --> K[Extract track sub-segment] - K --> L[Compute corridor bounding box] - L --> M[Run POI discovery with enabled sources] - M --> N[Generate result GPX] - N --> O{User choice} - O --> P[Send to OsmAnd via AIDL] - O --> Q[Share via Android chooser] -``` +See [docs/track-corridor-workflow.md](docs/track-corridor-workflow.md) for the Track Corridor workflow diagram. ## Build diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..10cad17 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,33 @@ +# Architecture + +``` +presentation/ — Compose UI + MainActivity.kt — main screen with coordinate input and POI generation + SettingsScreen.kt — API keys and source configuration + MapPickerSheet.kt — bottom-sheet map for picking coordinates + TrackCorridorScreen.kt — Track Corridor workflow (OsmAnd integration) + theme/ — Material 3 theme (Color, Theme, Type) + +domain/ + models/ — PointData, Coordinates + repository/ — SettingsRepository interface + +data/repository/ — implementations + SettingsRepositoryImpl.kt — SharedPreferences-backed settings + AllAttractionsGenerator.kt — orchestrates all POI sources + GooglePlaceGpxGenerator.kt — Google Places API + OsmPlaceGpxGenerator.kt — OpenStreetMap / Overpass + TripAdvisorGpxGenerator.kt — TripAdvisor Content API + WikidataAttractionsGpxGenerator.kt — Wikidata SPARQL + WikipediaArticlesGpxGenerator.kt — Wikipedia geo-search + INaturalistGpxGenerator.kt — iNaturalist species + NeedPhotoWikidataGpxGenerator.kt — Wikidata objects without photos + NeedPhotoSettings.kt — exclusion categories for photo routes + GpxGeneratorBase.kt — shared GPX-writing logic + IGpxGenerator.kt — generator interface + NominatimService.kt — reverse geocoding + OsmAndConnection.kt — AIDL binding to OsmAnd + GpxTrackParser.kt — SAX-based GPX track parser + CorridorCalculator.kt — haversine distance, sub-segment extraction, bounding box + TrackCacheRepository.kt — track-name → file-URI cache +``` diff --git a/docs/track-corridor-workflow.md b/docs/track-corridor-workflow.md new file mode 100644 index 0000000..7c479cd --- /dev/null +++ b/docs/track-corridor-workflow.md @@ -0,0 +1,24 @@ +# Track Corridor Workflow + +```mermaid +flowchart TD + A[Open Track Corridor screen] --> B{OsmAnd installed?} + B -- No --> B1[Show 'Install OsmAnd' message] + B -- Yes --> C[Bind to OsmAnd via AIDL] + C --> D[Fetch active & imported tracks] + D --> E{Tracks found?} + E -- No --> E1[Show 'No tracks found'] + E -- Yes --> F[User selects a track] + F --> G{GPX file cached?} + G -- Yes --> H[Load GPX from cached URI] + G -- No --> I[User picks GPX file] + I --> H + H --> J[User enters start/end km + corridor width m] + J --> K[Extract track sub-segment] + K --> L[Compute corridor bounding box] + L --> M[Run POI discovery with enabled sources] + M --> N[Generate result GPX] + N --> O{User choice} + O --> P[Send to OsmAnd via AIDL] + O --> Q[Share via Android chooser] +``` From 0a49f0b5dab442362651d9c059f739a12d227766 Mon Sep 17 00:00:00 2001 From: Boris Bukin Date: Fri, 5 Jun 2026 12:20:40 +0300 Subject: [PATCH 05/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../presentation/TrackCorridorScreen.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt index a878c73..3b68142 100644 --- a/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt +++ b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt @@ -226,11 +226,12 @@ fun TrackCorridorScreen(onNavigateBack: () -> Unit) { fun shareFile() { val file = generatedFile ?: return val uri = FileProvider.getUriForFile(context, "com.example.googleAttractionsGpx.fileProvider", file) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/octet-stream") + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/gpx+xml" + putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - context.startActivity(Intent.createChooser(intent, "Open GPX")) + context.startActivity(Intent.createChooser(intent, "Share GPX")) } Scaffold( From 9114e678b11f819ff9640ecfd9f7482ed24cbc5b Mon Sep 17 00:00:00 2001 From: Boris Bukin Date: Fri, 5 Jun 2026 12:21:08 +0300 Subject: [PATCH 06/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../googleAttractionsGpx/presentation/TrackCorridorScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt index 3b68142..301a713 100644 --- a/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt +++ b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt @@ -192,7 +192,7 @@ fun TrackCorridorScreen(onNavigateBack: () -> Unit) { val gpxContent = if (includeTrack) { buildCorridorGpxContent(allPoints, segment) } else { - buildGpxContent(allPoints) + buildCorridorGpxContent(allPoints, emptyList()) } val fileName = "corridor_${selectedTrack}_${"%.0f".format(start)}-${"%.0f".format(end)}km.gpx" From 326b213b4106ef8297dc38c07db48d39276ce31a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:24:36 +0000 Subject: [PATCH 07/11] Fix ServiceConnection instance mismatch in OsmAndConnection bind/unbind --- .../data/repository/OsmAndConnection.kt | 21 +++++++------------ gradlew | 0 2 files changed, 7 insertions(+), 14 deletions(-) mode change 100644 => 100755 gradlew diff --git a/app/src/main/java/com/example/googleAttractionsGpx/data/repository/OsmAndConnection.kt b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/OsmAndConnection.kt index e77aa72..5fed938 100644 --- a/app/src/main/java/com/example/googleAttractionsGpx/data/repository/OsmAndConnection.kt +++ b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/OsmAndConnection.kt @@ -22,17 +22,7 @@ class OsmAndConnection(private val context: Context) { private var osmAndInterface: IOsmAndAidlInterface? = null private var boundPackage: String? = null - - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - osmAndInterface = IOsmAndAidlInterface.Stub.asInterface(service) - } - - override fun onServiceDisconnected(name: ComponentName?) { - osmAndInterface = null - boundPackage = null - } - } + private var serviceConnection: ServiceConnection? = null fun isOsmAndInstalled(): Boolean { val pkg = getOsmAndPackage() @@ -51,7 +41,7 @@ class OsmAndConnection(private val context: Context) { val intent = Intent("net.osmand.aidl.OsmandAidlServiceV2") intent.setPackage(pkg) Log.d(TAG, "bind: binding to action=net.osmand.aidl.OsmandAidlServiceV2 package=$pkg") - val bound = context.bindService(intent, object : ServiceConnection { + val conn = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { Log.d(TAG, "onServiceConnected: name=$name") osmAndInterface = IOsmAndAidlInterface.Stub.asInterface(service) @@ -64,7 +54,9 @@ class OsmAndConnection(private val context: Context) { osmAndInterface = null boundPackage = null } - }, Context.BIND_AUTO_CREATE) + } + serviceConnection = conn + val bound = context.bindService(intent, conn, Context.BIND_AUTO_CREATE) Log.d(TAG, "bind: bindService returned $bound") if (!bound) { Log.w(TAG, "bind: bindService failed") @@ -127,8 +119,9 @@ class OsmAndConnection(private val context: Context) { fun unbind() { try { - context.unbindService(serviceConnection) + serviceConnection?.let { context.unbindService(it) } } catch (_: Exception) {} + serviceConnection = null osmAndInterface = null boundPackage = null } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From c69b30ca9c58395e4b534975f329fef9fcabee3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:29:34 +0000 Subject: [PATCH 08/11] Add missing Bundle and IBinder imports to IOsmAndAidlInterface.aidl --- app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl b/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl index c45216c..d09c357 100644 --- a/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl +++ b/app/src/main/aidl/net/osmand/aidlapi/IOsmAndAidlInterface.aidl @@ -1,5 +1,7 @@ package net.osmand.aidlapi; +import android.os.Bundle; +import android.os.IBinder; import net.osmand.aidlapi.gpx.ASelectedGpxFile; import net.osmand.aidlapi.gpx.AGpxFile; import net.osmand.aidlapi.gpx.ImportGpxParams; From 12ffec4244e2a958673db3807e6a728697707ba7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:29:51 +0000 Subject: [PATCH 09/11] Disable DTDs and external entities in GpxTrackParser to prevent XXE --- .../googleAttractionsGpx/data/repository/GpxTrackParser.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/example/googleAttractionsGpx/data/repository/GpxTrackParser.kt b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/GpxTrackParser.kt index e5e0e3e..6e170b0 100644 --- a/app/src/main/java/com/example/googleAttractionsGpx/data/repository/GpxTrackParser.kt +++ b/app/src/main/java/com/example/googleAttractionsGpx/data/repository/GpxTrackParser.kt @@ -11,6 +11,9 @@ object GpxTrackParser { fun parseTrackPoints(input: InputStream): List { val points = mutableListOf() val factory = SAXParserFactory.newInstance() + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + factory.setFeature("http://xml.org/sax/features/external-general-entities", false) + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false) val parser = factory.newSAXParser() parser.parse(input, object : DefaultHandler() { override fun startElement(uri: String?, localName: String?, qName: String?, attributes: Attributes?) { From e61573bf05bab692dfe096b78c33ed984607f85b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:30:05 +0000 Subject: [PATCH 10/11] Validate non-negative start/end distances before extracting segment --- .../googleAttractionsGpx/presentation/TrackCorridorScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt index 301a713..8325cba 100644 --- a/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt +++ b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt @@ -139,6 +139,7 @@ fun TrackCorridorScreen(onNavigateBack: () -> Unit) { if (start == null || end == null || width == null) { statusText = "Please enter valid numbers for all fields"; return } + if (start < 0 || end < 0) { statusText = "Start and end distances must be non-negative"; return } if (start >= end) { statusText = "Start distance must be less than end distance"; return } if (end > totalLengthKm) { statusText = "End distance exceeds track length (${"%.1f".format(totalLengthKm)} km)"; return From eec339480e6bfbd5350f7f74317491ff4a49716b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:41:46 +0000 Subject: [PATCH 11/11] Unbind OsmAnd service when TrackCorridorScreen leaves composition --- .../googleAttractionsGpx/presentation/TrackCorridorScreen.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt index 8325cba..45c37f8 100644 --- a/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt +++ b/app/src/main/java/com/example/googleAttractionsGpx/presentation/TrackCorridorScreen.kt @@ -53,6 +53,10 @@ fun TrackCorridorScreen(onNavigateBack: () -> Unit) { var generatedFile by remember { mutableStateOf(null) } var needsFilePick by remember { mutableStateOf(false) } + DisposableEffect(osmAnd) { + onDispose { osmAnd.unbind() } + } + val filePickerLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() ) { uri: Uri? ->