OSM fallback for geocoding (#611)

* OSM fallback for geocoding

* fallback to city for missing province
This commit is contained in:
callebtc
2026-01-15 11:45:22 +07:00
committed by GitHub
parent 5ec02c99fd
commit 41945e6ff0
7 changed files with 282 additions and 65 deletions
@@ -0,0 +1,52 @@
package com.bitchat.android.geohash
import android.content.Context
import android.location.Address
import android.location.Geocoder
import android.os.Build
import android.util.Log
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
private val geocoder = Geocoder(context, Locale.getDefault())
private val TAG = "AndroidGeocoderProvider"
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
suspendCancellableCoroutine { cont ->
try {
geocoder.getFromLocation(
latitude,
longitude,
maxResults,
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: MutableList<Address>) {
if (cont.isActive) cont.resume(addresses)
}
override fun onError(errorMessage: String?) {
if (cont.isActive) {
Log.e(TAG, "Geocode error: $errorMessage")
cont.resume(emptyList())
}
}
}
)
} catch (e: Exception) {
if (cont.isActive) cont.resumeWithException(e)
}
}
} else {
@Suppress("DEPRECATION")
try {
geocoder.getFromLocation(latitude, longitude, maxResults) ?: emptyList()
} catch (e: Exception) {
Log.e(TAG, "Geocode failed", e)
emptyList()
}
}
}
}
@@ -0,0 +1,19 @@
package com.bitchat.android.geohash
import android.content.Context
import android.location.Geocoder
/**
* Factory to provide the best available geocoder.
*/
object GeocoderFactory {
fun get(context: Context): GeocoderProvider {
// If Google Play Services Geocoder is present, use it.
// Otherwise, fall back to OpenStreetMap.
return if (Geocoder.isPresent()) {
AndroidGeocoderProvider(context)
} else {
OpenStreetMapGeocoderProvider()
}
}
}
@@ -0,0 +1,13 @@
package com.bitchat.android.geohash
import android.location.Address
/**
* Interface for reverse geocoding providers.
*/
interface GeocoderProvider {
/**
* Get a list of Address objects from latitude and longitude.
*/
suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address>
}
@@ -167,12 +167,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
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 geocoderProvider = GeocoderFactory.get(context)
val name: String? = if (gh.length <= 2) {
// Composite admin name from multiple points
val b = Geohash.decodeToBounds(gh)
@@ -186,9 +185,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
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 list = geocoderProvider.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)
@@ -203,9 +201,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
}
} else {
val center = Geohash.decodeToCenter(gh)
@Suppress("DEPRECATION")
val list = geocoder.getFromLocation(center.first, center.second, 1)
val a = list?.firstOrNull()
val list = geocoderProvider.getFromLocation(center.first, center.second, 1)
val a = list.firstOrNull()
pickNameForLength(gh.length, a)
}
@@ -47,7 +47,7 @@ class LocationChannelManager private constructor(private val context: Context) {
}
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val geocoder: Geocoder = Geocoder(context, Locale.getDefault())
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
private var lastLocation: Location? = null
private var geocodingJob: Job? = null
private val gson = Gson()
@@ -538,11 +538,6 @@ class LocationChannelManager private constructor(private val context: Context) {
}
private fun reverseGeocodeIfNeeded(location: Location) {
if (!Geocoder.isPresent()) {
Log.w(TAG, "Geocoder not present on this device")
return
}
// Cancel any pending geocoding job to avoid race conditions
geocodingJob?.cancel()
@@ -550,58 +545,17 @@ class LocationChannelManager private constructor(private val context: Context) {
try {
Log.d(TAG, "Starting reverse geocoding")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
// Use new listener-based API for Android 13+
suspendCancellableCoroutine<Unit> { cont ->
try {
geocoder.getFromLocation(
location.latitude,
location.longitude,
1,
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: MutableList<android.location.Address>) {
if (!cont.isActive) return
if (addresses.isNotEmpty()) {
val address = addresses[0]
val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.value = names
} else {
Log.w(TAG, "No reverse geocoding results")
}
cont.resume(Unit) {}
}
val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
override fun onError(errorMessage: String?) {
if (!cont.isActive) return
Log.e(TAG, "Reverse geocoding failed: $errorMessage")
cont.resume(Unit) {}
}
}
)
} catch (e: Exception) {
if (!cont.isActive) return@suspendCancellableCoroutine
Log.e(TAG, "Error initiating geocoding listener: ${e.message}")
cont.resume(Unit) {}
}
cont.invokeOnCancellation {
// Listener-based API has no explicit unregister, so we just ignore callbacks
}
}
if (!isActive) return@launch
if (addresses.isNotEmpty()) {
val address = addresses[0]
val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.value = names
} else {
@Suppress("DEPRECATION")
val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
if (!isActive) return@launch
if (!addresses.isNullOrEmpty()) {
val address = addresses[0]
val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.value = names
} else {
Log.w(TAG, "No reverse geocoding results")
}
Log.w(TAG, "No reverse geocoding results")
}
} catch (e: Exception) {
if (e !is CancellationException) {
@@ -619,11 +573,13 @@ class LocationChannelManager private constructor(private val context: Context) {
dict[GeohashChannelLevel.REGION] = it
}
// Province (state/province or county)
// Province (state/province or county or city)
address.adminArea?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.PROVINCE] = it
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.PROVINCE] = it
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.PROVINCE] = it
}
// City (locality)
@@ -0,0 +1,106 @@
package com.bitchat.android.geohash
import android.location.Address
import android.util.Log
import com.bitchat.android.net.OkHttpProvider
import com.google.gson.Gson
import okhttp3.Request
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class OpenStreetMapGeocoderProvider : GeocoderProvider {
private val TAG = "OSMGeocoderProvider"
private val gson = Gson()
private val userAgent = "Bitchat-Android/1.0"
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
return withContext(Dispatchers.IO) {
val lang = Locale.getDefault().toLanguageTag()
// Using format=jsonv2 for structured address breakdown
val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$latitude&lon=$longitude&zoom=18&addressdetails=1&accept-language=$lang"
try {
val request = Request.Builder()
.url(url)
.header("User-Agent", userAgent)
.build()
val response = OkHttpProvider.httpClient().newCall(request).execute()
if (!response.isSuccessful) {
Log.e(TAG, "OSM Request failed: ${response.code}")
response.close()
return@withContext emptyList<Address>()
}
val body = response.body?.string()
response.close()
if (body.isNullOrEmpty()) return@withContext emptyList<Address>()
try {
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
if (osmResponse?.address == null) return@withContext emptyList<Address>()
val address = mapToAddress(osmResponse, latitude, longitude)
listOf(address)
} catch (e: Exception) {
Log.e(TAG, "OSM Parse failed: ${e.message}")
emptyList<Address>()
}
} catch (e: Exception) {
Log.e(TAG, "OSM Geocoding failed", e)
emptyList<Address>()
}
}
}
private fun mapToAddress(res: OsmResponse, lat: Double, lon: Double): Address {
val address = Address(Locale.getDefault())
address.latitude = lat
address.longitude = lon
val a = res.address ?: return address
address.countryName = a.country
address.adminArea = a.state
address.subAdminArea = a.county
// City logic similar to Google's mapping
address.locality = a.city ?: a.town ?: a.village ?: a.hamlet
// Neighborhood logic
address.subLocality = a.suburb ?: a.neighbourhood ?: a.residential ?: a.quarter
address.postalCode = a.postcode
address.thoroughfare = a.road
// Feature name
address.featureName = res.name
return address
}
// Data classes for JSON parsing
private data class OsmResponse(
val name: String?,
val display_name: String?,
val address: OsmAddress?
)
private data class OsmAddress(
val country: String?,
val state: String?,
val county: String?,
val city: String?,
val town: String?,
val village: String?,
val hamlet: String?,
val suburb: String?,
val neighbourhood: String?,
val residential: String?,
val quarter: String?,
val postcode: String?,
val road: String?
)
}