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