diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5244db5f --- /dev/null +++ b/AGENTS.md @@ -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.* diff --git a/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt new file mode 100644 index 00000000..670d4cfc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt @@ -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
{ + 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
) { + 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() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/GeocoderFactory.kt b/app/src/main/java/com/bitchat/android/geohash/GeocoderFactory.kt new file mode 100644 index 00000000..2c1e809c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/GeocoderFactory.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt new file mode 100644 index 00000000..bb4d69c2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt @@ -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
+} diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt index d4ccb9bf..81d83b79 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -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() 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) } diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index e330a1aa..d6542310 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -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 { cont -> - try { - geocoder.getFromLocation( - location.latitude, - location.longitude, - 1, - object : Geocoder.GeocodeListener { - override fun onGeocode(addresses: MutableList) { - 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) diff --git a/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt new file mode 100644 index 00000000..587d2970 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt @@ -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
{ + 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
() + } + + val body = response.body?.string() + response.close() + + if (body.isNullOrEmpty()) return@withContext emptyList
() + + try { + val osmResponse = gson.fromJson(body, OsmResponse::class.java) + if (osmResponse?.address == null) return@withContext emptyList
() + + val address = mapToAddress(osmResponse, latitude, longitude) + listOf(address) + } catch (e: Exception) { + Log.e(TAG, "OSM Parse failed: ${e.message}") + emptyList
() + } + } catch (e: Exception) { + Log.e(TAG, "OSM Geocoding failed", e) + emptyList
() + } + } + } + + 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? + ) +}