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
+74
View File
@@ -0,0 +1,74 @@
# Bitchat Android - Agent Guide
This document provides context, architectural insights, and development standards for AI agents working on the Bitchat Android codebase.
## 1. Project Overview
**Bitchat** is a decentralized, off-grid communication application focused on privacy and censorship resistance. It utilizes mesh networking (primarily Bluetooth LE and Tor/Arti) to enable peer-to-peer messaging without centralized servers.
**Key Technologies:**
- **Language:** Kotlin (JVM Target 1.8)
- **UI Framework:** Jetpack Compose (Material 3)
- **Asynchronous:** Kotlin Coroutines & Flow
- **Networking:** Bluetooth Low Energy (BLE), Tor (Arti Rust bridge), OkHttp
- **Architecture:** MVVM with Clean Architecture principles
- **Build System:** Gradle (Kotlin DSL)
## 2. Architecture & Directory Structure
The application follows a clean architecture pattern, heavily modularized by feature within the `app` module.
**Root Package:** `com.bitchat.android`
| Directory | Purpose |
|-----------|---------|
| `ui/` | **Presentation Layer**: Jetpack Compose screens, themes, and ViewModels. |
| `service/` | **Core Service**: Contains `MeshForegroundService`, managing persistent background connectivity. |
| `mesh/` | **Mesh Networking**: Logic for peer discovery, advertising, and message routing. |
| `protocol/` | **Wire Protocol**: Definitions of messages exchanged between peers. |
| `crypto/` | **Security**: Cryptographic primitives and key management. |
| `noise/` | **Encryption**: Implementation of the Noise Protocol Framework for secure channels. |
| `identity/` | **User Identity**: Management of user profiles and public/private keys. |
| `features/` | **App Features**: Sub-modules for `voice`, `file`, and `media` handling. |
| `nostr/` | **Relay Integration**: Logic for Nostr protocol integration and relay management. |
| `geohash/` | **Location**: Utilities for location-based features and geohashing. |
| `net/` | **Networking**: General network utilities and abstractions. |
## 3. Key Components
### UI Layer (Jetpack Compose)
- **Activity**: Single-Activity architecture (`MainActivity.kt`).
- **Navigation**: Jetpack Compose Navigation.
- **State Management**: `ViewModel` exposing `StateFlow` to Composables.
- **Theme**: Custom theme definitions in `ui/theme`.
### Networking & Connectivity
- **MeshForegroundService**: The critical component that keeps the mesh network alive. It manages the lifecycle of BLE scanning/advertising and other transport layers.
- **BLE Stack**: Located in `mesh/` and `net/`, handles the intricacies of Android Bluetooth interactions.
- **Tor/Arti**: Integrated via JNI (`jniLibs`) to provide anonymous internet routing where available.
## 4. Development Standards
### Code Style
- **Kotlin**: Adhere to official Kotlin coding conventions.
- **Compose**: Use functional components. Hoist state to ViewModels where possible.
- **Coroutines**: Use `suspend` functions for all I/O operations. strictly avoid blocking the main thread.
- **Naming**: Clear, descriptive names. Follow standard Android naming patterns (e.g., `*ViewModel`, `*Repository`, `*Screen`).
### Testing
- **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing.
- **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing.
- **Execution**:
- Unit: `./gradlew test`
- Instrumented: `./gradlew connectedAndroidTest`
## 5. Critical Constraints & Gotchas
1. **Permissions**: The app relies heavily on dangerous runtime permissions (Location, Bluetooth Scan/Connect/Advertise, Audio Recording). Always verify permission handling patterns in `MainActivity` or permission wrappers before adding new hardware features.
2. **Hardware Dependency**: Features like BLE are difficult to emulate. When writing code for these, focus on robust error handling and defensive programming as hardware behavior can be flaky.
3. **Background Limits**: Android enforces strict background execution limits. Network operations intended to persist must be tied to the `MeshForegroundService`.
## 6. Common Tasks
- **Build Debug APK**: `./gradlew assembleDebug`
- **Lint Check**: `./gradlew lint`
- **Clean Build**: `./gradlew clean`
---
*Note: This file is intended to assist AI agents in navigating and modifying the codebase efficiently. Always verify context by reading the actual files before making changes.*
@@ -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,17 +545,10 @@ 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
val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
if (!isActive) return@launch
if (addresses.isNotEmpty()) {
val address = addresses[0]
val names = namesByLevel(address)
@@ -569,40 +557,6 @@ class LocationChannelManager private constructor(private val context: Context) {
} else {
Log.w(TAG, "No reverse geocoding results")
}
cont.resume(Unit) {}
}
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
}
}
} 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")
}
}
} catch (e: Exception) {
if (e !is CancellationException) {
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
@@ -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?
)
}