From 0f7bcdba3898ab5673a029323e036168dd97c723 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 1 Apr 2026 18:01:39 -0400 Subject: [PATCH] Redesigns the GeoHash library for performance. --- amethyst/build.gradle | 3 - .../topNavFeeds/aroundMe/AroundMeExpander.kt | 2 +- .../amethyst/service/location/GeoHash.kt | 268 ++++++++++++++++++ .../service/location/LocationState.kt | 2 - .../ui/note/creators/location/LoadCityName.kt | 2 +- gradle/libs.versions.toml | 2 - 6 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index f49b2265f..5b4516875 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -349,9 +349,6 @@ dependencies { implementation libs.vico.charts.compose implementation libs.vico.charts.m3 - // GeoHash - implementation libs.drfonfon.geohash - // Waveform visualizer implementation libs.audiowaveform diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt index 3b09904d6..dd5325011 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/aroundMe/AroundMeExpander.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe -import com.fonfon.kgeohash.GeoHash +import com.vitorpamplona.amethyst.service.location.GeoHash fun compute50kmLine(geoHash: GeoHash): List { val hashes = mutableListOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt new file mode 100644 index 000000000..4741b104a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/GeoHash.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.location + +import android.location.Location + +/** + * GeoHash encoding/decoding based on Gustavo Niemeyer's algorithm (2008). + * + * Bits are interleaved: even-indexed bits (0, 2, 4, ...) = longitude, + * odd-indexed bits (1, 3, 5, ...) = latitude. + */ +class GeoHash private constructor( + // Left-aligned: MSB is bit 0 of the hash + private val bits: Long, + private val significantBits: Int, + val centerLat: Double, + val centerLon: Double, +) { + companion object { + const val BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz" + private const val BITS_PER_CHAR = 5 + const val MAX_CHAR_PRECISION = 12 + private const val MAX_BITS = 64 + private const val LAT_MAX = 90.0 + private const val LON_MAX = 180.0 + + // ASCII → base32 index; -1 for invalid characters + private val CHAR_INDEX = + IntArray(128) { -1 }.also { + BASE32.forEachIndexed { i, c -> it[c.code] = i } + } + + /** Encode [lat]/[lon] into a GeoHash with [charsCount] character precision. */ + fun encode( + lat: Double, + lon: Double, + charsCount: Int, + ): GeoHash { + require(charsCount in 1..MAX_CHAR_PRECISION) + val targetBits = charsCount * BITS_PER_CHAR + val latRange = doubleArrayOf(-LAT_MAX, LAT_MAX) + val lonRange = doubleArrayOf(-LON_MAX, LON_MAX) + var bits = 0L + var isEven = true + + repeat(targetBits) { + if (isEven) { + val mid = (lonRange[0] + lonRange[1]) * 0.5 + if (lon >= mid) { + bits = (bits shl 1) or 1L + lonRange[0] = mid + } else { + bits = bits shl 1 + lonRange[1] = mid + } + } else { + val mid = (latRange[0] + latRange[1]) * 0.5 + if (lat >= mid) { + bits = (bits shl 1) or 1L + latRange[0] = mid + } else { + bits = bits shl 1 + latRange[1] = mid + } + } + isEven = !isEven + } + + return GeoHash( + bits = bits shl (MAX_BITS - targetBits), + significantBits = targetBits, + centerLat = (latRange[0] + latRange[1]) * 0.5, + centerLon = (lonRange[0] + lonRange[1]) * 0.5, + ) + } + + /** Decode a GeoHash string. Returns null if the string is invalid. */ + fun decode(hash: String): GeoHash? { + if (hash.isEmpty() || hash.length > MAX_CHAR_PRECISION) return null + val latRange = doubleArrayOf(-LAT_MAX, LAT_MAX) + val lonRange = doubleArrayOf(-LON_MAX, LON_MAX) + var bits = 0L + var isEven = true + + for (c in hash) { + val code = c.code + if (code >= 128) return null + val index = CHAR_INDEX[code] + if (index < 0) return null + + for (j in 4 downTo 0) { + val bitSet = (index ushr j) and 1 == 1 + if (isEven) { + val mid = (lonRange[0] + lonRange[1]) * 0.5 + if (bitSet) { + bits = (bits shl 1) or 1L + lonRange[0] = mid + } else { + bits = bits shl 1 + lonRange[1] = mid + } + } else { + val mid = (latRange[0] + latRange[1]) * 0.5 + if (bitSet) { + bits = (bits shl 1) or 1L + latRange[0] = mid + } else { + bits = bits shl 1 + latRange[1] = mid + } + } + isEven = !isEven + } + } + + val sigBits = hash.length * BITS_PER_CHAR + return GeoHash( + bits = bits shl (MAX_BITS - sigBits), + significantBits = sigBits, + centerLat = (latRange[0] + latRange[1]) * 0.5, + centerLon = (lonRange[0] + lonRange[1]) * 0.5, + ) + } + + /** + * Reconstruct a GeoHash by re-interleaving separated lat/lon bit arrays. + * Used internally to compute cardinal neighbors. + */ + private fun fromBitArrays( + latBits: Long, + numLat: Int, + lonBits: Long, + numLon: Int, + ): GeoHash { + // Left-align both arrays + var lb = latBits shl (MAX_BITS - numLat) + var lob = lonBits shl (MAX_BITS - numLon) + val total = numLat + numLon + + val latRange = doubleArrayOf(-LAT_MAX, LAT_MAX) + val lonRange = doubleArrayOf(-LON_MAX, LON_MAX) + var newBits = 0L + // isEven=false → lon first (even-indexed positions = lon in GeoHash) + var isEven = false + + repeat(total) { + if (isEven) { + val bitSet = lb < 0L + lb = lb shl 1 + val mid = (latRange[0] + latRange[1]) * 0.5 + if (bitSet) { + newBits = (newBits shl 1) or 1L + latRange[0] = mid + } else { + newBits = newBits shl 1 + latRange[1] = mid + } + } else { + val bitSet = lob < 0L + lob = lob shl 1 + val mid = (lonRange[0] + lonRange[1]) * 0.5 + if (bitSet) { + newBits = (newBits shl 1) or 1L + lonRange[0] = mid + } else { + newBits = newBits shl 1 + lonRange[1] = mid + } + } + isEven = !isEven + } + + return GeoHash( + newBits shl (MAX_BITS - total), + total, + (latRange[0] + latRange[1]) * 0.5, + (lonRange[0] + lonRange[1]) * 0.5, + ) + } + } + + // Longitude gets the extra bit when significantBits is odd (lon is encoded first) + private val numLat = significantBits / 2 + private val numLon = significantBits / 2 + significantBits % 2 + + /** + * Extracts every other bit from [source] (positions 0, 2, 4, …) into a right-aligned value. + * Used to separate the interleaved lat/lon bit streams. + */ + private fun extractEvery2ndBit( + source: Long, + count: Int, + ): Long { + var src = source + var result = 0L + repeat(count) { + result = result shl 1 + if (src < 0L) result = result or 1L + src = src shl 2 + } + return result + } + + /** Keep only the lowest [n] bits of [value], wrapping neighbors at poles/antimeridian. */ + private fun mask( + value: Long, + n: Int, + ): Long = value and (-1L ushr (MAX_BITS - n)) + + // Lat bits occupy odd positions (1, 3, 5, …); shift left 1 to align them to even positions. + private val latBits get() = extractEvery2ndBit(bits shl 1, numLat) + + // Lon bits already occupy even positions (0, 2, 4, …). + private val lonBits get() = extractEvery2ndBit(bits, numLon) + + val northernNeighbour: GeoHash get() = fromBitArrays(mask(latBits + 1L, numLat), numLat, lonBits, numLon) + val southernNeighbour: GeoHash get() = fromBitArrays(mask(latBits - 1L, numLat), numLat, lonBits, numLon) + val easternNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits + 1L, numLon), numLon) + val westernNeighbour: GeoHash get() = fromBitArrays(latBits, numLat, mask(lonBits - 1L, numLon), numLon) + + fun toLocation(): Location = + Location("").also { + it.latitude = centerLat + it.longitude = centerLon + } + + override fun toString(): String { + val charCount = significantBits / BITS_PER_CHAR + val sb = StringBuilder(charCount) + var b = bits + repeat(charCount) { + sb.append(BASE32[(b ushr 59).toInt() and 0x1f]) + b = b shl BITS_PER_CHAR + } + return sb.toString() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is GeoHash) return false + return bits == other.bits && significantBits == other.significantBits + } + + override fun hashCode(): Int = 31 * bits.hashCode() + significantBits +} + +fun Location.toGeoHash(charsCount: Int = GeoHash.MAX_CHAR_PRECISION): GeoHash = GeoHash.encode(latitude, longitude, charsCount) + +fun String.toGeoHash(): GeoHash? = GeoHash.decode(this) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index d3a8d15d8..46b719ecc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.service.location import android.content.Context -import com.fonfon.kgeohash.GeoHash -import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt index a07c33dd0..4420368b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt @@ -27,8 +27,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import com.fonfon.kgeohash.toGeoHash import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations +import com.vitorpamplona.amethyst.service.location.toGeoHash import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e03f4b08..cc6e06459 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,6 @@ agp = "9.1.0" android-compileSdk = "36" android-minSdk = "26" android-targetSdk = "36" -androidKotlinGeohash = "b481c6a64e" androidxJunit = "1.3.0" appcompat = "1.7.1" audiowaveform = "1.1.2" @@ -124,7 +123,6 @@ commons-imaging = { group = "org.apache.commons", name = "commons-imaging", vers slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } -drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } jetbrains-compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "jetbrainsCompose" }