mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 21:45:21 +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,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,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