diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeocodeResult.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeocodeResult.kt index 8d2b1880598ed173390edf05fefed825fa9c554e..77ae2dab8e0e0c83867b7f191af007e76473f2cb 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeocodeResult.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeocodeResult.kt @@ -21,6 +21,7 @@ package earth.maps.cardinal.data import kotlin.math.abs data class GeocodeResult( + val geocodeId: String, val latitude: Double, val longitude: Double, val displayName: String, diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt index 130fa44964367cea8084d5dcf8ffa17733d5027f..12a71bbf90a13d13b288b03f47b489b66fe01041 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt @@ -464,7 +464,9 @@ class LocationRepository @Inject constructor( fun createSearchResultPlace(result: GeocodeResult): Place { val openingHours = result.properties["opening_hours"] + return Place( + id = result.geocodeId, name = result.displayName, description = mapOsmTagsToDescription(result.properties), icon = "search", diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/PlaceIdGenerator.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/PlaceIdGenerator.kt new file mode 100644 index 0000000000000000000000000000000000000000..49143b37a7b19d44c9dff01d1eaa26e20e8b7475 --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/PlaceIdGenerator.kt @@ -0,0 +1,89 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.data + +import java.nio.ByteBuffer +import java.security.MessageDigest + +/** + * A utility for generating deterministic, stable identifiers for geographical places. + * + * This generator uses a combination of coordinates and the place's display name to create + * a SHA-256 hash. Using raw byte buffers for coordinates ensures that the ID remains + * consistent across different device locales (avoiding decimal separator issues like '.' vs ','). + * + * Including the [name] in the generation process prevents ID collisions in scenarios where + * multiple distinct points of interest (POIs) exist at the exact same coordinates. + */ +object PlaceIdGenerator { + /** + * The number of bytes required to store two Double values (Latitude and Longitude). + */ + private const val COORD_BYTE_SIZE = 16 + + /** + * Generates a stable hex ID based on the provided location and name. + * + * @param latitude The latitude of the place. + * @param longitude The longitude of the place. + * @param name The display name or title of the place. + * @return A deterministic SHA-256 hash string in hexadecimal format. + */ + fun generateId(latitude: Double, longitude: Double, name: String): String { + val nameBytes = name.toByteArray(Charsets.UTF_8) + + // Allocate space for coordinates (16 bytes) + name bytes + val buffer = ByteBuffer.allocate(COORD_BYTE_SIZE + nameBytes.size) + buffer.putDouble(latitude) + buffer.putDouble(longitude) + buffer.put(nameBytes) + + return hashBytesToHex(buffer.array()) + } + + /** + * Returns a stable identifier for the given [place]. + * + * If the [place] already contains a valid ID (e.g., from an online provider like Pelias), + * that ID is returned. If the ID is null or blank (common with offline geocoding results), + * a deterministic ID is generated based on the place's coordinates and name. + * + * @param place The place object to get or generate an ID for. + * @return The existing ID if present, otherwise a generated SHA-256 hex string. + */ + fun generateId(place: Place): String { + return if (place.id.isNullOrBlank()) generateId( + latitude = place.latLng.latitude, + longitude = place.latLng.longitude, + name = place.name + ) else place.id + } + + /** + * Hashes a byte array using SHA-256 and converts it to a hex string. + * + * Implementation note: Uses [joinToString] for linear-time string construction + * to avoid excessive memory allocations. + */ + private fun hashBytesToHex(bytes: ByteArray): String { + return MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString("") { "%02x".format(it) } + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/RecentSearch.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/RecentSearch.kt index d48bfeba610fa1981caac53200bba43861bd9d4d..990aaf1f158861b9c34f07b22a428e0dd4f3abeb 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/RecentSearch.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/RecentSearch.kt @@ -21,7 +21,7 @@ package earth.maps.cardinal.data.room import androidx.room.Entity import androidx.room.PrimaryKey import earth.maps.cardinal.data.Place -import java.util.UUID +import earth.maps.cardinal.data.PlaceIdGenerator @Entity(tableName = "recent_searches") data class RecentSearch( @@ -46,7 +46,7 @@ data class RecentSearch( val timestamp = System.currentTimeMillis() return RecentSearch( - id = UUID.randomUUID().toString(), + id = PlaceIdGenerator.generateId(place), name = place.name, description = place.description, icon = place.icon, diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedPlace.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedPlace.kt index 666370162fe1d004249b34654076b7eaa076117e..d48ea6a3b52552de18d73a6238a3021ab19e7df0 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedPlace.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedPlace.kt @@ -21,7 +21,7 @@ package earth.maps.cardinal.data.room import androidx.room.Entity import androidx.room.PrimaryKey import earth.maps.cardinal.data.Place -import java.util.UUID +import earth.maps.cardinal.data.PlaceIdGenerator @Entity(tableName = "saved_places") data class SavedPlace( @@ -53,9 +53,8 @@ data class SavedPlace( companion object { fun fromPlace(place: Place): SavedPlace { val timestamp = System.currentTimeMillis() - return SavedPlace( - id = UUID.randomUUID().toString(), + id = PlaceIdGenerator.generateId(place), placeId = 0, customName = null, customDescription = null, diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/OfflineGeocodingService.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/OfflineGeocodingService.kt index b72b88b206e54cab610f1f08a86530b62d0ce013..d4812efc12d270b8b6f2a393cb015dfbcda50d0f 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/OfflineGeocodingService.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/OfflineGeocodingService.kt @@ -25,6 +25,7 @@ import earth.maps.cardinal.data.Address import earth.maps.cardinal.data.GeocodeResult import earth.maps.cardinal.data.LatLng import earth.maps.cardinal.data.LocationRepository +import earth.maps.cardinal.data.PlaceIdGenerator import uniffi.cardinal_geocoder.newAirmailIndex import java.io.File @@ -98,8 +99,13 @@ class OfflineGeocodingService( // Populate address from available tags val address = buildAddress(tags) - + val id = PlaceIdGenerator.generateId( + latitude = latitude, + longitude = longitude, + name = displayName, + ) return GeocodeResult( + geocodeId = id, displayName = displayName, latitude = latitude, longitude = longitude, diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/PeliasGeocodingService.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/PeliasGeocodingService.kt index aa5617231d440350854ccc1498caa058e8db5563..5b8030be331896beadeff01e596c0bb2dd4b5e71 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/PeliasGeocodingService.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/geocoding/PeliasGeocodingService.kt @@ -24,6 +24,7 @@ import earth.maps.cardinal.data.AppPreferenceRepository import earth.maps.cardinal.data.GeocodeResult import earth.maps.cardinal.data.LatLng import earth.maps.cardinal.data.LocationRepository +import earth.maps.cardinal.data.PlaceIdGenerator import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.android.Android @@ -36,6 +37,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -192,6 +194,11 @@ class PeliasGeocodingService( } GeocodeResult( + geocodeId = properties?.get("gid")?.jsonPrimitive?.contentOrNull ?: PlaceIdGenerator.generateId( + latitude = lat, + longitude = lon, + name = displayName + ), latitude = lat, longitude = lon, displayName = displayName, diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/data/PlaceIdGeneratorTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/PlaceIdGeneratorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f1a7ce4a13d57cedc93b8152cbd72ad6ad5648e4 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/PlaceIdGeneratorTest.kt @@ -0,0 +1,200 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.data + +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Locale + +class PlaceIdGeneratorTest { + + @Test + fun `generateId is deterministic for the same input`() { + val lat = 19.1136 + val lon = 72.8697 + val name = "JB Nagar" + + val id1 = PlaceIdGenerator.generateId(lat, lon, name) + val id2 = PlaceIdGenerator.generateId(lat, lon, name) + + assertEquals("Same input must produce the same ID", id1, id2) + } + + @Test + fun `generateId produces different IDs for different names at same coordinates`() { + val lat = 19.1136 + val lon = 72.8697 + + val id1 = PlaceIdGenerator.generateId(lat, lon, "Coffee Shop") + val id2 = PlaceIdGenerator.generateId(lat, lon, "Law Firm") + + assertNotEquals("Different names at same coordinates must produce different IDs", id1, id2) + } + + @Test + fun `generateId produces different IDs for different coordinates with same name`() { + val name = "Starbucks" + + val id1 = PlaceIdGenerator.generateId(19.1136, 72.8697, name) + val id2 = PlaceIdGenerator.generateId(18.9218, 72.8347, name) + + assertNotEquals("Same name at different coordinates must produce different IDs", id1, id2) + } + + @Test + fun `generateId is locale independent`() { + val lat = 19.1136 + val lon = 72.8697 + val name = "JB Nagar" + + // Store original locale + val originalLocale = Locale.getDefault() + + try { + // Set locale to US (uses '.' as decimal separator) + Locale.setDefault(Locale.US) + val idUS = PlaceIdGenerator.generateId(lat, lon, name) + + // Set locale to GERMANY (uses ',' as decimal separator) + Locale.setDefault(Locale.GERMANY) + val idGermany = PlaceIdGenerator.generateId(lat, lon, name) + + assertEquals( + "ID must be identical regardless of system decimal separators", + idUS, + idGermany + ) + } finally { + // Restore locale to avoid affecting other tests + Locale.setDefault(originalLocale) + } + } + + @Test + fun `generateId handles special characters and emojis in name`() { + val lat = 0.0 + val lon = 0.0 + val name1 = "Müchen Café ☕" + val name2 = "Müchen Café ☕" + + val id1 = PlaceIdGenerator.generateId(lat, lon, name1) + val id2 = PlaceIdGenerator.generateId(lat, lon, name2) + + assertEquals("Special characters and emojis should be handled deterministically", id1, id2) + } + + @Test + fun `generateId produces a valid 64 character SHA-256 hex string`() { + val id = PlaceIdGenerator.generateId(1.0, 1.0, "Test") + + // SHA-256 produces 32 bytes, which is 64 hex characters + assertEquals("ID length should be 64 characters", 64, id.length) + + // Verify it only contains hex characters + val hexRegex = Regex("^[0-9a-fA-F]+$") + assertTrue(id.matches(hexRegex)) + } + + @Test + fun `generateId with existing ID returns the original ID`() { + val existingId = "osm:way:12345" + val place = mockk { + every { id } returns existingId + } + + val result = PlaceIdGenerator.generateId(place) + + assertEquals("Should return the ID already present in the Place object", existingId, result) + } + + @Test + fun `generateId with null or blank ID generates a deterministic hash`() { + val latitude = 45.523062 + val longitude = -122.676482 + val placeName = "Pioneer Courthouse Square" + + val place = mockk { + every { id } returns "" // or null + every { latLng } returns LatLng(latitude, longitude) + every { name } returns placeName + } + + val result = PlaceIdGenerator.generateId(place) + + // Calculate expected hash using the existing primitive function to ensure parity + val expected = PlaceIdGenerator.generateId(latitude, longitude, placeName) + + assertEquals("Generated ID should match the hash of coordinates and name", expected, result) + assertNotEquals("Result should not be blank", "", result) + } + + @Test + fun `generateId is stable for identical offline places`() { + val lat = 52.5200 + val lon = 13.4050 + val placeName = "Berlin TV Tower" + + val place1 = mockk { + every { id } returns null + every { latLng } returns LatLng(lat, lon) + every { name } returns placeName + } + + val place2 = mockk { + every { id } returns "" + every { latLng } returns LatLng(lat, lon) + every { name } returns placeName + } + + assertEquals( + "Two different place objects with same data should produce the same ID", + PlaceIdGenerator.generateId(place1), + PlaceIdGenerator.generateId(place2) + ) + } + + @Test + fun `generateId differs when coordinates or name change`() { + val lat = 40.7128 + val lon = -74.0060 + + val placeA = mockk { + every { id } returns null + every { latLng } returns LatLng(lat, lon) + every { name } returns "Location A" + } + + val placeB = mockk { + every { id } returns null + every { latLng } returns LatLng(lat, lon) + every { name } returns "Location B" + } + + assertNotEquals( + "Places at the same coordinates but with different names should have different IDs", + PlaceIdGenerator.generateId(placeA), + PlaceIdGenerator.generateId(placeB) + ) + } + +} \ No newline at end of file