mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 08:25:22 +00:00
feat: geohash bookmarks (iOS parity) (#410)
* Geohash bookmarks (iOS parity): - Add GeohashBookmarksStore (persist bookmarks + friendly names) - Integrate bookmark toggle into LocationChannelsSheet (nearby + bookmarked sections) - Add header toggle for current geohash bookmark - Sample counts for union of nearby + bookmarks while sheet open - Add Geohash.decodeToBounds() to support friendly name resolution - Fix coroutine usage and Gson type inference issues * fix UI * clear bookmarks on triple tap * adjust icons --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,8 @@ object Geohash {
|
||||
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
|
||||
private val charToValue: Map<Char, Int> = base32Chars.withIndex().associate { it.value to it.index }
|
||||
|
||||
data class Bounds(val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double)
|
||||
|
||||
/**
|
||||
* Encodes the provided coordinates into a geohash string.
|
||||
* @param latitude Latitude in degrees (-90...90)
|
||||
@@ -69,14 +71,24 @@ object Geohash {
|
||||
* @return Pair(latitude, longitude)
|
||||
*/
|
||||
fun decodeToCenter(geohash: String): Pair<Double, Double> {
|
||||
if (geohash.isEmpty()) return 0.0 to 0.0
|
||||
val b = decodeToBounds(geohash)
|
||||
val latCenter = (b.latMin + b.latMax) / 2
|
||||
val lonCenter = (b.lonMin + b.lonMax) / 2
|
||||
return latCenter to lonCenter
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a geohash string to bounding box (lat/lon min/max).
|
||||
*/
|
||||
fun decodeToBounds(geohash: String): Bounds {
|
||||
if (geohash.isEmpty()) return Bounds(0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
var latInterval = -90.0 to 90.0
|
||||
var lonInterval = -180.0 to 180.0
|
||||
var isEven = true
|
||||
|
||||
geohash.lowercase().forEach { ch ->
|
||||
val cd = charToValue[ch] ?: return 0.0 to 0.0
|
||||
val cd = charToValue[ch] ?: return Bounds(0.0, 0.0, 0.0, 0.0)
|
||||
for (mask in intArrayOf(16, 8, 4, 2, 1)) {
|
||||
if (isEven) {
|
||||
val mid = (lonInterval.first + lonInterval.second) / 2
|
||||
@@ -96,9 +108,11 @@ object Geohash {
|
||||
isEven = !isEven
|
||||
}
|
||||
}
|
||||
|
||||
val latCenter = (latInterval.first + latInterval.second) / 2
|
||||
val lonCenter = (lonInterval.first + lonInterval.second) / 2
|
||||
return latCenter to lonCenter
|
||||
return Bounds(
|
||||
latMin = minOf(latInterval.first, latInterval.second),
|
||||
latMax = maxOf(latInterval.first, latInterval.second),
|
||||
lonMin = minOf(lonInterval.first, lonInterval.second),
|
||||
lonMax = maxOf(lonInterval.first, lonInterval.second)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Stores a user-maintained list of bookmarked geohash channels.
|
||||
* - Persistence: SharedPreferences (JSON string array)
|
||||
* - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||
*/
|
||||
class GeohashBookmarksStore private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GeohashBookmarksStore"
|
||||
private const val STORE_KEY = "locationChannel.bookmarks"
|
||||
private const val NAMES_STORE_KEY = "locationChannel.bookmarkNames"
|
||||
|
||||
@Volatile private var INSTANCE: GeohashBookmarksStore? = null
|
||||
fun getInstance(context: Context): GeohashBookmarksStore {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: GeohashBookmarksStore(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
|
||||
private val allowedChars = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||||
fun normalize(raw: String): String {
|
||||
return raw.trim().lowercase(Locale.US)
|
||||
.replace("#", "")
|
||||
.filter { allowedChars.contains(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val prefs = context.getSharedPreferences("geohash_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val membership = mutableSetOf<String>()
|
||||
|
||||
private val _bookmarks = MutableLiveData<List<String>>(emptyList())
|
||||
val bookmarks: LiveData<List<String>> = _bookmarks
|
||||
|
||||
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap())
|
||||
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames
|
||||
|
||||
// For throttling / preventing duplicate geocode lookups
|
||||
private val resolving = mutableSetOf<String>()
|
||||
|
||||
init { load() }
|
||||
|
||||
fun isBookmarked(geohash: String): Boolean = membership.contains(normalize(geohash))
|
||||
|
||||
fun toggle(geohash: String) {
|
||||
val gh = normalize(geohash)
|
||||
if (membership.contains(gh)) remove(gh) else add(gh)
|
||||
}
|
||||
|
||||
fun add(geohash: String) {
|
||||
val gh = normalize(geohash)
|
||||
if (gh.isEmpty() || membership.contains(gh)) return
|
||||
membership.add(gh)
|
||||
_bookmarks.postValue(listOf(gh) + (_bookmarks.value ?: emptyList()))
|
||||
persist()
|
||||
// Resolve friendly name asynchronously
|
||||
resolveNameIfNeeded(gh)
|
||||
}
|
||||
|
||||
fun remove(geohash: String) {
|
||||
val gh = normalize(geohash)
|
||||
if (!membership.contains(gh)) return
|
||||
membership.remove(gh)
|
||||
_bookmarks.postValue((_bookmarks.value ?: emptyList()).filterNot { it == gh })
|
||||
// Remove stored name to avoid stale cache growth
|
||||
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
|
||||
if (names.remove(gh) != null) {
|
||||
_bookmarkNames.postValue(names)
|
||||
persistNames()
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private fun load() {
|
||||
try {
|
||||
val arrJson = prefs.getString(STORE_KEY, null)
|
||||
if (!arrJson.isNullOrEmpty()) {
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
val arr = gson.fromJson<List<String>>(arrJson, listType)
|
||||
val seen = mutableSetOf<String>()
|
||||
val ordered = mutableListOf<String>()
|
||||
arr.forEach { raw ->
|
||||
val gh = normalize(raw)
|
||||
if (gh.isNotEmpty() && !seen.contains(gh)) {
|
||||
seen.add(gh)
|
||||
ordered.add(gh)
|
||||
}
|
||||
}
|
||||
membership.clear(); membership.addAll(seen)
|
||||
_bookmarks.postValue(ordered)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
|
||||
}
|
||||
try {
|
||||
val namesJson = prefs.getString(NAMES_STORE_KEY, null)
|
||||
if (!namesJson.isNullOrEmpty()) {
|
||||
val mapType = object : TypeToken<Map<String, String>>() {}.type
|
||||
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
|
||||
_bookmarkNames.postValue(dict)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
try {
|
||||
val json = gson.toJson(_bookmarks.value ?: emptyList<String>())
|
||||
prefs.edit().putString(STORE_KEY, json).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun persistNames() {
|
||||
try {
|
||||
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>())
|
||||
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// MARK: - Destructive Reset
|
||||
|
||||
fun clearAll() {
|
||||
try {
|
||||
membership.clear()
|
||||
_bookmarks.postValue(emptyList())
|
||||
_bookmarkNames.postValue(emptyMap())
|
||||
prefs.edit()
|
||||
.remove(STORE_KEY)
|
||||
.remove(NAMES_STORE_KEY)
|
||||
.apply()
|
||||
// Clear any in-flight resolutions to avoid repopulating
|
||||
resolving.clear()
|
||||
Log.i(TAG, "Cleared all geohash bookmarks and names")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear geohash bookmarks: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Friendly Name Resolution
|
||||
|
||||
fun resolveNameIfNeeded(geohash: String) {
|
||||
val gh = normalize(geohash)
|
||||
if (gh.isEmpty()) return
|
||||
if (_bookmarkNames.value?.containsKey(gh) == true) return
|
||||
if (resolving.contains(gh)) return
|
||||
if (!Geocoder.isPresent()) return
|
||||
|
||||
resolving.add(gh)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val geocoder = Geocoder(context, Locale.getDefault())
|
||||
val name: String? = if (gh.length <= 2) {
|
||||
// Composite admin name from multiple points
|
||||
val b = Geohash.decodeToBounds(gh)
|
||||
val points = listOf(
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = (b.latMin + b.latMax) / 2; longitude = (b.lonMin + b.lonMax) / 2 },
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMin },
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMax },
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMin },
|
||||
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMax }
|
||||
)
|
||||
val admins = linkedSetOf<String>()
|
||||
for (loc in points) {
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
val list = geocoder.getFromLocation(loc.latitude, loc.longitude, 1)
|
||||
val a = list?.firstOrNull()
|
||||
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
|
||||
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
|
||||
if (admin != null) admins.add(admin)
|
||||
else if (country != null) admins.add(country)
|
||||
} catch (_: Exception) {}
|
||||
if (admins.size >= 2) break
|
||||
}
|
||||
when (admins.size) {
|
||||
0 -> null
|
||||
1 -> admins.first()
|
||||
else -> admins.elementAt(0) + " and " + admins.elementAt(1)
|
||||
}
|
||||
} else {
|
||||
val center = Geohash.decodeToCenter(gh)
|
||||
@Suppress("DEPRECATION")
|
||||
val list = geocoder.getFromLocation(center.first, center.second, 1)
|
||||
val a = list?.firstOrNull()
|
||||
pickNameForLength(gh.length, a)
|
||||
}
|
||||
|
||||
if (!name.isNullOrEmpty()) {
|
||||
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
|
||||
current[gh] = name
|
||||
_bookmarkNames.postValue(current)
|
||||
persistNames()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Name resolution failed for #$gh: ${e.message}")
|
||||
} finally {
|
||||
resolving.remove(gh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickNameForLength(len: Int, address: android.location.Address?): String? {
|
||||
if (address == null) return null
|
||||
return when (len) {
|
||||
in 0..2 -> address.adminArea ?: address.countryName
|
||||
in 3..4 -> address.adminArea ?: address.subAdminArea ?: address.countryName
|
||||
5 -> address.locality ?: address.subAdminArea ?: address.adminArea
|
||||
in 6..7 -> address.subLocality ?: address.locality ?: address.adminArea
|
||||
else -> address.subLocality ?: address.locality ?: address.adminArea ?: address.countryName
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user