mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:45:20 +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 (gh.isEmpty()) return
|
||||||
if (_bookmarkNames.value?.containsKey(gh) == true) return
|
if (_bookmarkNames.value?.containsKey(gh) == true) return
|
||||||
if (resolving.contains(gh)) return
|
if (resolving.contains(gh)) return
|
||||||
if (!Geocoder.isPresent()) return
|
|
||||||
|
|
||||||
resolving.add(gh)
|
resolving.add(gh)
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
val geocoder = Geocoder(context, Locale.getDefault())
|
val geocoderProvider = GeocoderFactory.get(context)
|
||||||
val name: String? = if (gh.length <= 2) {
|
val name: String? = if (gh.length <= 2) {
|
||||||
// Composite admin name from multiple points
|
// Composite admin name from multiple points
|
||||||
val b = Geohash.decodeToBounds(gh)
|
val b = Geohash.decodeToBounds(gh)
|
||||||
@@ -186,9 +185,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
val admins = linkedSetOf<String>()
|
val admins = linkedSetOf<String>()
|
||||||
for (loc in points) {
|
for (loc in points) {
|
||||||
try {
|
try {
|
||||||
@Suppress("DEPRECATION")
|
val list = geocoderProvider.getFromLocation(loc.latitude, loc.longitude, 1)
|
||||||
val list = geocoder.getFromLocation(loc.latitude, loc.longitude, 1)
|
val a = list.firstOrNull()
|
||||||
val a = list?.firstOrNull()
|
|
||||||
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
|
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
|
||||||
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
|
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
|
||||||
if (admin != null) admins.add(admin)
|
if (admin != null) admins.add(admin)
|
||||||
@@ -203,9 +201,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val center = Geohash.decodeToCenter(gh)
|
val center = Geohash.decodeToCenter(gh)
|
||||||
@Suppress("DEPRECATION")
|
val list = geocoderProvider.getFromLocation(center.first, center.second, 1)
|
||||||
val list = geocoder.getFromLocation(center.first, center.second, 1)
|
val a = list.firstOrNull()
|
||||||
val a = list?.firstOrNull()
|
|
||||||
pickNameForLength(gh.length, a)
|
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 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 lastLocation: Location? = null
|
||||||
private var geocodingJob: Job? = null
|
private var geocodingJob: Job? = null
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
@@ -538,11 +538,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun reverseGeocodeIfNeeded(location: Location) {
|
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
|
// Cancel any pending geocoding job to avoid race conditions
|
||||||
geocodingJob?.cancel()
|
geocodingJob?.cancel()
|
||||||
|
|
||||||
@@ -550,17 +545,10 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
try {
|
try {
|
||||||
Log.d(TAG, "Starting reverse geocoding")
|
Log.d(TAG, "Starting reverse geocoding")
|
||||||
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
|
||||||
// Use new listener-based API for Android 13+
|
|
||||||
suspendCancellableCoroutine<Unit> { cont ->
|
if (!isActive) return@launch
|
||||||
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()) {
|
if (addresses.isNotEmpty()) {
|
||||||
val address = addresses[0]
|
val address = addresses[0]
|
||||||
val names = namesByLevel(address)
|
val names = namesByLevel(address)
|
||||||
@@ -569,40 +557,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "No reverse geocoding results")
|
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) {
|
} catch (e: Exception) {
|
||||||
if (e !is CancellationException) {
|
if (e !is CancellationException) {
|
||||||
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
|
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
|
||||||
@@ -619,11 +573,13 @@ class LocationChannelManager private constructor(private val context: Context) {
|
|||||||
dict[GeohashChannelLevel.REGION] = it
|
dict[GeohashChannelLevel.REGION] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Province (state/province or county)
|
// Province (state/province or county or city)
|
||||||
address.adminArea?.takeIf { it.isNotEmpty() }?.let {
|
address.adminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||||
dict[GeohashChannelLevel.PROVINCE] = it
|
dict[GeohashChannelLevel.PROVINCE] = it
|
||||||
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
|
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||||
dict[GeohashChannelLevel.PROVINCE] = it
|
dict[GeohashChannelLevel.PROVINCE] = it
|
||||||
|
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
dict[GeohashChannelLevel.PROVINCE] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// City (locality)
|
// 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