mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:25:19 +00:00
OSM fallback for geocoding (#611)
* OSM fallback for geocoding * fallback to city for missing province
This commit is contained in:
@@ -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?
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user