Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt +7 −13 Original line number Diff line number Diff line Loading @@ -37,7 +37,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.nio.ByteBuffer import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton Loading Loading @@ -466,18 +465,13 @@ class LocationRepository @Inject constructor( fun createSearchResultPlace(result: GeocodeResult): Place { val openingHours = result.properties["opening_hours"] val osmId = result.properties["osm_id"] val osmType = result.properties["osm_type"] val deterministicId = if (osmId != null) { "osm:$osmType:$osmId" } else { // 2. Fallback: Use a binary hash of coordinates (Locale Independent) // We use Long bits to ensure 1.11.111 is different from 11.1.111 val buffer = ByteBuffer.allocate(16) buffer.putDouble(result.latitude) buffer.putDouble(result.longitude) generateStableIdFromBytes(buffer.array()) } val deterministicId = PlaceIdGenerator.generateId( latitude = result.latitude, longitude = result.longitude, name = result.displayName ) return Place( id = deterministicId, name = result.displayName, Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/PlaceIdGenerator.kt 0 → 100644 +69 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. */ 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()) } /** * 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 cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/RecentSearch.kt +8 −2 Original line number Diff line number Diff line Loading @@ -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( Loading @@ -45,8 +45,14 @@ data class RecentSearch( fun fromPlace(place: Place): RecentSearch { val timestamp = System.currentTimeMillis() val deterministicId = if (place.id.isNullOrBlank()) PlaceIdGenerator.generateId( latitude = place.latLng.latitude, longitude = place.latLng.longitude, name = place.name ) else place.id return RecentSearch( id = if (place.id.isNullOrBlank()) UUID.randomUUID().toString() else place.id, id = deterministicId, name = place.name, description = place.description, icon = place.icon, Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedPlace.kt +7 −3 Original line number Diff line number Diff line Loading @@ -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( Loading Loading @@ -53,9 +53,13 @@ data class SavedPlace( companion object { fun fromPlace(place: Place): SavedPlace { val timestamp = System.currentTimeMillis() val deterministicId = if (place.id.isNullOrBlank()) PlaceIdGenerator.generateId( latitude = place.latLng.latitude, longitude = place.latLng.longitude, name = place.name ) else place.id return SavedPlace( id = if (place.id.isNullOrBlank()) UUID.randomUUID().toString() else place.id, id = deterministicId, placeId = 0, customName = null, customDescription = null, Loading cardinal-android/app/src/test/java/earth/maps/cardinal/data/PlaceIdGeneratorTest.kt 0 → 100644 +114 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. */ package earth.maps.cardinal.data import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals 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]+$") assert(id.matches(hexRegex)) { "ID should be a valid hexadecimal string" } } } No newline at end of file Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt +7 −13 Original line number Diff line number Diff line Loading @@ -37,7 +37,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.nio.ByteBuffer import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton Loading Loading @@ -466,18 +465,13 @@ class LocationRepository @Inject constructor( fun createSearchResultPlace(result: GeocodeResult): Place { val openingHours = result.properties["opening_hours"] val osmId = result.properties["osm_id"] val osmType = result.properties["osm_type"] val deterministicId = if (osmId != null) { "osm:$osmType:$osmId" } else { // 2. Fallback: Use a binary hash of coordinates (Locale Independent) // We use Long bits to ensure 1.11.111 is different from 11.1.111 val buffer = ByteBuffer.allocate(16) buffer.putDouble(result.latitude) buffer.putDouble(result.longitude) generateStableIdFromBytes(buffer.array()) } val deterministicId = PlaceIdGenerator.generateId( latitude = result.latitude, longitude = result.longitude, name = result.displayName ) return Place( id = deterministicId, name = result.displayName, Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/PlaceIdGenerator.kt 0 → 100644 +69 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. */ 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()) } /** * 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
cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/RecentSearch.kt +8 −2 Original line number Diff line number Diff line Loading @@ -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( Loading @@ -45,8 +45,14 @@ data class RecentSearch( fun fromPlace(place: Place): RecentSearch { val timestamp = System.currentTimeMillis() val deterministicId = if (place.id.isNullOrBlank()) PlaceIdGenerator.generateId( latitude = place.latLng.latitude, longitude = place.latLng.longitude, name = place.name ) else place.id return RecentSearch( id = if (place.id.isNullOrBlank()) UUID.randomUUID().toString() else place.id, id = deterministicId, name = place.name, description = place.description, icon = place.icon, Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/SavedPlace.kt +7 −3 Original line number Diff line number Diff line Loading @@ -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( Loading Loading @@ -53,9 +53,13 @@ data class SavedPlace( companion object { fun fromPlace(place: Place): SavedPlace { val timestamp = System.currentTimeMillis() val deterministicId = if (place.id.isNullOrBlank()) PlaceIdGenerator.generateId( latitude = place.latLng.latitude, longitude = place.latLng.longitude, name = place.name ) else place.id return SavedPlace( id = if (place.id.isNullOrBlank()) UUID.randomUUID().toString() else place.id, id = deterministicId, placeId = 0, customName = null, customDescription = null, Loading
cardinal-android/app/src/test/java/earth/maps/cardinal/data/PlaceIdGeneratorTest.kt 0 → 100644 +114 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. */ package earth.maps.cardinal.data import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals 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]+$") assert(id.matches(hexRegex)) { "ID should be a valid hexadecimal string" } } } No newline at end of file