Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 07bd4689 authored by mitulsheth's avatar mitulsheth
Browse files

fix(favorites): removing places from the favorites

- AI code review changes 2
- Created new class for placeId generation with Unit Test
parent 49367ce6
Loading
Loading
Loading
Loading
Loading
+7 −13
Original line number Diff line number Diff line
@@ -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
@@ -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,
+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
+8 −2
Original line number Diff line number Diff line
@@ -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(
@@ -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,
+7 −3
Original line number Diff line number Diff line
@@ -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,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,
+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