From 09c5c74c0989a84518fd3463c9dcb3c0bc22cfe9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:45:27 +0200 Subject: [PATCH] fix: enforce soft location privacy gate --- .../java/com/bitchat/android/MainActivity.kt | 2 +- .../geohash/AndroidGeocoderProvider.kt | 64 ++- .../android/geohash/FusedLocationProvider.kt | 48 ++- .../android/geohash/GeocoderProvider.kt | 7 +- .../android/geohash/GeohashBookmarksStore.kt | 8 +- .../geohash/GeohashNostrPrivacyPolicy.kt | 24 ++ .../geohash/LiveLocationPrivacyGate.kt | 119 ++++++ .../android/geohash/LocationChannelManager.kt | 404 ++++++++++++------ .../android/geohash/LocationProvider.kt | 2 +- .../geohash/OpenStreetMapGeocoderProvider.kt | 88 ++-- .../android/geohash/SystemLocationProvider.kt | 59 ++- .../android/nostr/LocationNotesInitializer.kt | 20 +- .../android/nostr/LocationNotesManager.kt | 152 ++++--- .../com/bitchat/android/nostr/NostrClient.kt | 6 +- .../bitchat/android/nostr/NostrIdentity.kt | 11 +- .../android/nostr/NostrRelayManager.kt | 376 +++++++++++----- .../android/nostr/NostrSubscriptionManager.kt | 45 +- .../bitchat/android/nostr/RelayDirectory.kt | 3 +- .../com/bitchat/android/ui/ChatViewModel.kt | 50 +-- .../com/bitchat/android/ui/DataManager.kt | 7 +- .../bitchat/android/ui/GeohashViewModel.kt | 234 +++++++--- .../android/ui/LocationChannelsSheet.kt | 65 +-- .../android/ui/MessageInteractionUtils.kt | 4 +- .../bitchat/android/ui/NotificationManager.kt | 8 +- .../geohash/GeohashNostrPrivacyPolicyTest.kt | 46 ++ .../geohash/LiveLocationAccessPolicyTest.kt | 48 +++ 26 files changed, 1384 insertions(+), 516 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt create mode 100644 app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt create mode 100644 app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt create mode 100644 app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index e6344efa..56cd3509 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -817,7 +817,7 @@ class MainActivity : OrientationAwareActivity() { val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH) if (geohash != null) { - Log.d("MainActivity", "Opening geohash chat #$geohash from notification") + Log.d("MainActivity", "Opening geohash chat from notification") // Switch to the geohash channel - create appropriate geohash channel level val level = when (geohash.length) { diff --git a/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt index 670d4cfc..5731a6f6 100644 --- a/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt @@ -14,27 +14,44 @@ 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
{ + override suspend fun getFromLocation( + latitude: Double, + longitude: Double, + maxResults: Int, + liveLocationToken: Long? + ): 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) - } + val startRequest = { + 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()) + override fun onError(errorMessage: String?) { + if (cont.isActive) { + Log.e(TAG, "Geocode error") + cont.resume(emptyList()) + } } } - } - ) + ) + } + val started = if (liveLocationToken == null) { + startRequest() + true + } else { + LiveLocationPrivacyGate.runIfAllowed( + liveLocationToken, + startRequest + ) + } + if (!started && cont.isActive) cont.resume(emptyList()) } catch (e: Exception) { if (cont.isActive) cont.resumeWithException(e) } @@ -42,9 +59,22 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider { } else { @Suppress("DEPRECATION") try { - geocoder.getFromLocation(latitude, longitude, maxResults) ?: emptyList() + var addresses: List
= emptyList() + val request = { + addresses = geocoder.getFromLocation( + latitude, + longitude, + maxResults + ) ?: emptyList() + } + if (liveLocationToken == null) { + request() + } else { + LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, request) + } + addresses } catch (e: Exception) { - Log.e(TAG, "Geocode failed", e) + Log.e(TAG, "Geocode failed") emptyList() } } diff --git a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt index b6d29c90..06439c80 100644 --- a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt @@ -9,8 +9,9 @@ import android.os.Looper import android.util.Log import androidx.core.app.ActivityCompat import com.google.android.gms.location.* +import com.google.android.gms.tasks.CancellationTokenSource -class FusedLocationProvider(private val context: Context) : LocationProvider { +internal class FusedLocationProvider(private val context: Context) : LocationProvider { companion object { private const val TAG = "FusedLocationProvider" @@ -20,10 +21,13 @@ class FusedLocationProvider(private val context: Context) : LocationProvider { // Map to keep track of callbacks to remove them later private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>() + private val activeCurrentLocationRequests = mutableSetOf() private fun hasLocationPermission(): Boolean { - return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + return LiveLocationPrivacyGate.isEnabled && + (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + ) } @SuppressLint("MissingPermission") @@ -36,14 +40,14 @@ class FusedLocationProvider(private val context: Context) : LocationProvider { try { fusedLocationClient.lastLocation .addOnSuccessListener { location -> - callback(location) + callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) } .addOnFailureListener { e -> - Log.e(TAG, "Error getting last known fused location: ${e.message}") + Log.e(TAG, "Error getting last-known fused location") callback(null) } } catch (e: Exception) { - Log.e(TAG, "Exception getting last known fused location: ${e.message}") + Log.e(TAG, "Exception getting last-known fused location") callback(null) } } @@ -60,17 +64,27 @@ class FusedLocationProvider(private val context: Context) : LocationProvider { .setPriority(Priority.PRIORITY_HIGH_ACCURACY) .setDurationMillis(30000) .build() + val cancellation = CancellationTokenSource() - fusedLocationClient.getCurrentLocation(request, null) + synchronized(activeCurrentLocationRequests) { + activeCurrentLocationRequests.add(cancellation) + } + + fusedLocationClient.getCurrentLocation(request, cancellation.token) .addOnSuccessListener { location -> - callback(location) + callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) } .addOnFailureListener { e -> - Log.e(TAG, "Error getting fresh fused location: ${e.message}") + Log.e(TAG, "Error getting fresh fused location") callback(null) } + .addOnCompleteListener { + synchronized(activeCurrentLocationRequests) { + activeCurrentLocationRequests.remove(cancellation) + } + } } catch (e: Exception) { - Log.e(TAG, "Exception getting fresh fused location: ${e.message}") + Log.e(TAG, "Exception getting fresh fused location") callback(null) } } @@ -91,7 +105,9 @@ class FusedLocationProvider(private val context: Context) : LocationProvider { val locationCallback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { - result.lastLocation?.let { callback(it) } + if (LiveLocationPrivacyGate.isEnabled) { + result.lastLocation?.let { callback(it) } + } } } @@ -107,7 +123,7 @@ class FusedLocationProvider(private val context: Context) : LocationProvider { Log.d(TAG, "Registered fused updates") } catch (e: Exception) { - Log.e(TAG, "Error requesting fused updates: ${e.message}") + Log.e(TAG, "Error requesting fused updates") } } @@ -122,21 +138,25 @@ class FusedLocationProvider(private val context: Context) : LocationProvider { Log.d(TAG, "Removed fused updates") } } catch (e: Exception) { - Log.e(TAG, "Error removing fused updates: ${e.message}") + Log.e(TAG, "Error removing fused updates") } } override fun cancel() { try { synchronized(activeCallbacks) { - for ((callback, locationCallback) in activeCallbacks) { + for ((_, locationCallback) in activeCallbacks) { fusedLocationClient.removeLocationUpdates(locationCallback) } activeCallbacks.clear() } + synchronized(activeCurrentLocationRequests) { + activeCurrentLocationRequests.forEach { it.cancel() } + activeCurrentLocationRequests.clear() + } Log.d(TAG, "Cancelled all fused updates") } catch (e: Exception) { - Log.e(TAG, "Error cancelling fused provider: ${e.message}") + Log.e(TAG, "Error cancelling fused provider") } } } diff --git a/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt index bb4d69c2..34197565 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeocoderProvider.kt @@ -9,5 +9,10 @@ interface GeocoderProvider { /** * Get a list of Address objects from latitude and longitude. */ - suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List
+ suspend fun getFromLocation( + latitude: Double, + longitude: Double, + maxResults: Int, + liveLocationToken: Long? = null + ): 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 81d83b79..92e237cb 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -112,7 +112,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { _bookmarks.value = ordered } } catch (e: Exception) { - Log.e(TAG, "Failed to load bookmarks: ${e.message}") + Log.e(TAG, "Failed to load bookmarks") } try { val namesJson = prefs.getString(NAMES_STORE_KEY, null) @@ -122,7 +122,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { _bookmarkNames.value = dict } } catch (e: Exception) { - Log.e(TAG, "Failed to load bookmark names: ${e.message}") + Log.e(TAG, "Failed to load bookmark names") } } @@ -155,7 +155,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { resolving.clear() Log.i(TAG, "Cleared all geohash bookmarks and names") } catch (e: Exception) { - Log.e(TAG, "Failed to clear geohash bookmarks: ${e.message}") + Log.e(TAG, "Failed to clear geohash bookmarks") } } @@ -213,7 +213,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { persistNames(current) } } catch (e: Exception) { - Log.w(TAG, "Name resolution failed for #$gh: ${e.message}") + Log.w(TAG, "Bookmark name resolution failed") } finally { resolving.remove(gh) } diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt new file mode 100644 index 00000000..26b9fd3c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicy.kt @@ -0,0 +1,24 @@ +package com.bitchat.android.geohash + +internal object GeohashNostrPrivacyPolicy { + fun livePresenceTargets( + availableChannels: Collection, + liveLocationEnabled: Boolean, + ): Set { + if (!liveLocationEnabled) return emptySet() + return availableChannels + .asSequence() + .filter { it.level.precision <= GeohashChannelLevel.CITY.precision } + .map { it.geohash } + .toSet() + } + + fun samplingTargets( + liveLocationGeohashes: Collection, + userSelectedGeohashes: Collection, + liveLocationEnabled: Boolean, + ): Set = buildSet { + addAll(userSelectedGeohashes) + if (liveLocationEnabled) addAll(liveLocationGeohashes) + } +} diff --git a/app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt b/app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt new file mode 100644 index 00000000..5a4f30fb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/LiveLocationPrivacyGate.kt @@ -0,0 +1,119 @@ +package com.bitchat.android.geohash + +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-wide, fail-closed consent gate for accessing live device location. + * + * A generation token prevents callbacks that were started under an older consent + * state from being accepted after location access is disabled or re-enabled. + */ +internal class LiveLocationAccessPolicy( + initialEnabled: Boolean = DEFAULT_LIVE_LOCATION_ENABLED, +) { + private val accessLock = ReentrantReadWriteLock() + private val generation = AtomicLong(0L) + private val _enabled = MutableStateFlow(initialEnabled) + private var accessAvailable = initialEnabled + + val enabled: StateFlow = _enabled.asStateFlow() + val isEnabled: Boolean + get() = _enabled.value + + fun update(enabled: Boolean) { + accessLock.write { + generation.incrementAndGet() + _enabled.value = enabled + accessAvailable = enabled + } + } + + fun invalidate() { + accessLock.write { + generation.incrementAndGet() + accessAvailable = false + } + } + + fun resumeAccess() { + accessLock.write { + if (_enabled.value && !accessAvailable) { + generation.incrementAndGet() + accessAvailable = true + } + } + } + + fun captureToken(): Long? = + accessLock.read { + val capturedGeneration = generation.get() + capturedGeneration.takeIf { + _enabled.value && + accessAvailable && + generation.get() == capturedGeneration + } + } + + fun accepts(token: Long): Boolean = + accessLock.read { + _enabled.value && accessAvailable && generation.get() == token + } + + fun runIfAllowed(token: Long, action: () -> Unit): Boolean = + accessLock.read { + if (!_enabled.value || !accessAvailable || generation.get() != token) { + false + } else { + action() + true + } + } +} + +internal const val DEFAULT_LIVE_LOCATION_ENABLED = false + +internal object LiveLocationPrivacyGate { + private val policy = LiveLocationAccessPolicy() + private val revocationListeners = CopyOnWriteArraySet<() -> Unit>() + + val enabled: StateFlow = policy.enabled + val isEnabled: Boolean + get() = policy.isEnabled + + fun update(enabled: Boolean) { + policy.update(enabled) + notifyRevoked() + } + + fun invalidate() { + policy.invalidate() + notifyRevoked() + } + + fun captureToken(): Long? = policy.captureToken() + fun resumeAccess() = policy.resumeAccess() + fun accepts(token: Long): Boolean = policy.accepts(token) + fun runIfAllowed(token: Long, action: () -> Unit): Boolean = + policy.runIfAllowed(token, action) + + fun addRevocationListener(listener: () -> Unit) { + revocationListeners.add(listener) + } + + fun removeRevocationListener(listener: () -> Unit) { + revocationListeners.remove(listener) + } + + private fun notifyRevoked() { + revocationListeners.forEach { listener -> + runCatching(listener) + } + } +} 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 6c91c32e..46f05950 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -4,16 +4,14 @@ import android.Manifest import android.content.Context import android.content.IntentFilter import android.content.pm.PackageManager -import android.location.Geocoder import android.location.Location import android.location.LocationManager -import android.os.Bundle import android.util.Log import androidx.core.app.ActivityCompat import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability +import com.bitchat.android.nostr.NostrIdentityBridge import kotlinx.coroutines.* -import java.util.* import com.google.gson.Gson import com.google.gson.JsonSyntaxException import kotlinx.coroutines.flow.MutableStateFlow @@ -47,13 +45,19 @@ class LocationChannelManager private constructor(private val context: Context) { AUTHORIZED } + enum class LocationSelectionSource { + NEARBY, + MANUAL + } + private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager private val locationProvider: LocationProvider private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context) - private var lastLocation: Location? = null private var geocodingJob: Job? = null private val gson = Gson() private var dataManager: com.bitchat.android.ui.DataManager? = null + private var selectedLocationSource: LocationSelectionSource? = null + private var activeLocationUpdateCallback: ((Location) -> Unit)? = null private fun checkSystemLocationEnabled(): Boolean { return try { @@ -70,14 +74,13 @@ class LocationChannelManager private constructor(private val context: Context) { val isEnabled = checkSystemLocationEnabled() Log.d(TAG, "System location state changed: $isEnabled") _systemLocationEnabled.value = isEnabled + if (!isEnabled) { + clearLiveLocationState() + } } } } - private val locationUpdateCallback: (Location) -> Unit = { location -> - onLocationUpdated(location) - } - // Published state for UI bindings (matching iOS @Published properties) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -99,8 +102,7 @@ class LocationChannelManager private constructor(private val context: Context) { private val _isLoadingLocation = MutableStateFlow(false) val isLoadingLocation: StateFlow = _isLoadingLocation - private val _locationServicesEnabled = MutableStateFlow(false) - val locationServicesEnabled: StateFlow = _locationServicesEnabled + val locationServicesEnabled: StateFlow = LiveLocationPrivacyGate.enabled private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled()) val systemLocationEnabled: StateFlow = _systemLocationEnabled @@ -127,12 +129,14 @@ class LocationChannelManager private constructor(private val context: Context) { Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)") SystemLocationProvider(context) } + LiveLocationPrivacyGate.addRevocationListener(::cancelLiveLocationWork) - checkAndSyncPermission() // Initialize DataManager and load persisted settings dataManager = com.bitchat.android.ui.DataManager(context) - loadPersistedChannelSelection() loadLocationServicesState() + syncPermissionState() + if (!_systemLocationEnabled.value) clearLiveLocationState() + loadPersistedChannelSelection() // Register for system location changes context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)) @@ -145,17 +149,13 @@ class LocationChannelManager private constructor(private val context: Context) { * UNIFIED: Only requests location if location services are enabled by user */ fun enableLocationChannels() { - if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) { + if (!LiveLocationPrivacyGate.isEnabled || !_systemLocationEnabled.value) { Log.w(TAG, "Location services disabled (app or system) - not requesting location") return } - - if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) { - _permissionState.value = PermissionState.AUTHORIZED + if (syncPermissionState() == PermissionState.AUTHORIZED) { requestOneShotLocation() - } else { - _permissionState.value = PermissionState.DENIED } } @@ -163,7 +163,7 @@ class LocationChannelManager private constructor(private val context: Context) { * Refresh available channels from current location */ fun refreshChannels() { - if (_permissionState.value == PermissionState.AUTHORIZED && isLocationServicesEnabled()) { + if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) { requestOneShotLocation() } } @@ -173,7 +173,7 @@ class LocationChannelManager private constructor(private val context: Context) { * Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately */ fun beginLiveRefresh(interval: Long = 5000L) { - if (_permissionState.value != PermissionState.AUTHORIZED) { + if (syncPermissionState() != PermissionState.AUTHORIZED) { Log.w(TAG, "Cannot start live refresh - permission not authorized") return } @@ -183,12 +183,28 @@ class LocationChannelManager private constructor(private val context: Context) { return } + endLiveRefresh() + LiveLocationPrivacyGate.resumeAccess() + val token = LiveLocationPrivacyGate.captureToken() ?: return + val callback: (Location) -> Unit = { location -> + if (canUseLiveLocation(token)) { + onLocationUpdated(location, token) + } + } + activeLocationUpdateCallback = callback + // Register for continuous updates from available provider - locationProvider.requestLocationUpdates( - intervalMs = interval, - minDistanceMeters = 5f, - callback = locationUpdateCallback - ) + val started = LiveLocationPrivacyGate.runIfAllowed(token) { + locationProvider.requestLocationUpdates( + intervalMs = interval, + minDistanceMeters = 5f, + callback = callback + ) + } + if (!started) { + activeLocationUpdateCallback = null + return + } // Prime state immediately with last known / current location requestOneShotLocation() @@ -198,53 +214,56 @@ class LocationChannelManager private constructor(private val context: Context) { * Stop periodic refreshes when selector UI is dismissed */ fun endLiveRefresh() { - locationProvider.removeLocationUpdates(locationUpdateCallback) + activeLocationUpdateCallback?.let(locationProvider::removeLocationUpdates) + activeLocationUpdateCallback = null } /** - * Select a channel + * Generic selection is intentionally treated as manual. GPS-derived selections must use + * [selectNearby] so their provenance can be revoked when live location is disabled. */ fun select(channel: ChannelID) { - Log.d(TAG, "Selected channel: ${channel.displayName}") - // Use synchronous set to avoid race with background recomputation - _selectedChannel.value = channel - saveChannelSelection(channel) - - // Immediately recompute teleported status against the latest known location - lastLocation?.let { location -> - when (channel) { - is ChannelID.Mesh -> { - _teleported.value = false - } - is ChannelID.Location -> { - val currentGeohash = Geohash.encode( - latitude = location.latitude, - longitude = location.longitude, - precision = channel.channel.level.precision - ) - val isTeleportedNow = currentGeohash != channel.channel.geohash - _teleported.value = isTeleportedNow - } - } + when (channel) { + ChannelID.Mesh -> selectInternal(ChannelID.Mesh, source = null, teleported = false) + is ChannelID.Location -> selectManual(channel.channel) } } - - /** - * Set teleported status (for manual geohash teleportation) - */ - fun setTeleported(teleported: Boolean) { - _teleported.value = teleported + + fun selectNearby(channel: GeohashChannel): Boolean { + val isCurrentNearbyChannel = _availableChannels.value.contains(channel) + if (!isCurrentNearbyChannel || !isLocationServicesEnabled() || + syncPermissionState() != PermissionState.AUTHORIZED + ) { + Log.w(TAG, "Blocked nearby channel selection without live-location access") + return false + } + selectInternal( + ChannelID.Location(channel), + source = LocationSelectionSource.NEARBY, + teleported = false + ) + return true + } + + fun selectManual(channel: GeohashChannel, teleported: Boolean = true) { + selectInternal( + ChannelID.Location(channel), + source = LocationSelectionSource.MANUAL, + teleported = teleported || !LiveLocationPrivacyGate.isEnabled + ) } /** * Enable location services (user-controlled toggle) */ fun enableLocationServices() { - _locationServicesEnabled.value = true - saveLocationServicesState(true) + if (!LiveLocationPrivacyGate.isEnabled) { + LiveLocationPrivacyGate.update(true) + saveLocationServicesState(true) + } // If we have permission and system location is on, start location operations - if (_permissionState.value == PermissionState.AUTHORIZED && systemLocationEnabled.value) { + if (syncPermissionState() == PermissionState.AUTHORIZED && systemLocationEnabled.value) { requestOneShotLocation() } } @@ -253,20 +272,9 @@ class LocationChannelManager private constructor(private val context: Context) { * Disable location services (user-controlled toggle) */ fun disableLocationServices() { - _locationServicesEnabled.value = false + LiveLocationPrivacyGate.update(false) saveLocationServicesState(false) - - // Stop any ongoing location operations - endLiveRefresh() - - // Clear available channels when location is disabled - _availableChannels.value = emptyList() - _locationNames.value = emptyMap() - - // If user had a location channel selected, switch back to mesh - if (_selectedChannel.value is ChannelID.Location) { - select(ChannelID.Mesh) - } + clearLiveLocationState(invalidateAccess = false) } /** @@ -276,70 +284,163 @@ class LocationChannelManager private constructor(private val context: Context) { * Check if both the app toggle and system location are enabled */ fun isLocationServicesEnabled(): Boolean { - return _locationServicesEnabled.value && _systemLocationEnabled.value + return LiveLocationPrivacyGate.isEnabled && _systemLocationEnabled.value + } + + fun canUseSelectedLocationChannel(channel: GeohashChannel): Boolean { + if (_selectedChannel.value != ChannelID.Location(channel)) return false + return selectedLocationSource == LocationSelectionSource.MANUAL || + LiveLocationPrivacyGate.captureToken() != null + } + + fun isSelectedChannelLiveDerived(channel: GeohashChannel): Boolean = + _selectedChannel.value == ChannelID.Location(channel) && + selectedLocationSource == LocationSelectionSource.NEARBY + + fun liveLocationTokenForSelectedChannel(channel: GeohashChannel): Long? { + if (!isSelectedChannelLiveDerived(channel)) return null + return LiveLocationPrivacyGate.captureToken() + } + + private fun selectInternal( + channel: ChannelID, + source: LocationSelectionSource?, + teleported: Boolean + ) { + selectedLocationSource = source + _teleported.value = when (channel) { + ChannelID.Mesh -> false + is ChannelID.Location -> teleported + } + _selectedChannel.value = channel + saveChannelSelection(channel, source) + } + + /** + * Revokes all GPS-derived in-memory state and pending work. This deliberately does not + * change the persisted soft setting when Android's hard permission or system provider is + * temporarily unavailable. + */ + private fun clearLiveLocationState(invalidateAccess: Boolean = true) { + if (invalidateAccess) LiveLocationPrivacyGate.invalidate() + cancelLiveLocationWork() + _isLoadingLocation.value = false + NostrIdentityBridge.clearGeohashIdentityCache( + _availableChannels.value.map { it.geohash } + ) + _availableChannels.value = emptyList() + _locationNames.value = emptyMap() + + when { + _selectedChannel.value is ChannelID.Location && + selectedLocationSource == LocationSelectionSource.NEARBY -> { + selectInternal(ChannelID.Mesh, source = null, teleported = false) + } + _selectedChannel.value is ChannelID.Location -> { + // Manual channels remain usable for teleports, bookmarks, and DMs. Never use + // a previously retained GPS fix to classify them while live access is off. + selectedLocationSource = LocationSelectionSource.MANUAL + _teleported.value = true + saveChannelSelection(_selectedChannel.value, selectedLocationSource) + } + } + } + + private fun cancelLiveLocationWork() { + locationProvider.cancel() + activeLocationUpdateCallback = null + geocodingJob?.cancel() + geocodingJob = null } // MARK: - Location Operations private fun requestOneShotLocation() { - if (!checkAndSyncPermission()) { + if (!isLocationServicesEnabled() || + syncPermissionState() != PermissionState.AUTHORIZED + ) { Log.w(TAG, "No location permission for one-shot request") return } - // Set loading state initially + LiveLocationPrivacyGate.resumeAccess() + val token = LiveLocationPrivacyGate.captureToken() ?: return _isLoadingLocation.value = true - locationProvider.getLastKnownLocation { cached -> - // If we have a cached location and it's reasonably recent (e.g. < 5 mins), use it - // For now, we just use it if it exists, similar to previous logic - if (cached != null) { - onLocationUpdated(cached) - } else { - locationProvider.requestFreshLocation { fresh -> - if (fresh != null) { - onLocationUpdated(fresh) - } else { - Log.w(TAG, "Failed to get fresh location") - _isLoadingLocation.value = false + val started = LiveLocationPrivacyGate.runIfAllowed(token) { + locationProvider.getLastKnownLocation { cached -> + if (!canUseLiveLocation(token)) return@getLastKnownLocation + + if (cached != null) { + onLocationUpdated(cached, token) + } else { + LiveLocationPrivacyGate.runIfAllowed(token) { + locationProvider.requestFreshLocation { fresh -> + if (!canUseLiveLocation(token)) return@requestFreshLocation + + if (fresh != null) { + onLocationUpdated(fresh, token) + } else { + Log.w(TAG, "Failed to get fresh location") + _isLoadingLocation.value = false + } + } } } } } + if (!started) _isLoadingLocation.value = false } - private fun onLocationUpdated(location: Location) { - lastLocation = location - _isLoadingLocation.value = false - computeChannels(location) - reverseGeocodeIfNeeded(location) + private fun onLocationUpdated(location: Location, token: Long) { + LiveLocationPrivacyGate.runIfAllowed(token) { + if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed + _isLoadingLocation.value = false + computeChannels(location, token) + reverseGeocodeIfNeeded(location, token) + } } // MARK: - Helpers - private fun getCurrentPermissionStatus(): PermissionState { - return if (checkAndSyncPermission()) { + private fun hasRuntimeLocationPermission(): Boolean { + return ActivityCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + fun syncPermissionState(): PermissionState { + val newState = if (hasRuntimeLocationPermission()) { PermissionState.AUTHORIZED } else { PermissionState.DENIED } - } - - private fun checkAndSyncPermission(): Boolean { - val hasPermission = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || - ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED - - val newState = if (hasPermission) PermissionState.AUTHORIZED else PermissionState.DENIED if (_permissionState.value != newState) { _permissionState.value = newState } - return hasPermission + if (newState == PermissionState.DENIED) { + clearLiveLocationState() + } + return newState } - private fun computeChannels(location: Location) { + private fun canUseLiveLocation(token: Long): Boolean { + return LiveLocationPrivacyGate.accepts(token) && + _systemLocationEnabled.value && + hasRuntimeLocationPermission() + } + + private fun computeChannels(location: Location, token: Long) { + if (!canUseLiveLocation(token)) return + val levels = GeohashChannelLevel.allCases() val result = mutableListOf() @@ -351,47 +452,55 @@ class LocationChannelManager private constructor(private val context: Context) { ) result.add(GeohashChannel(level = level, geohash = geohash)) } - + + if (!canUseLiveLocation(token)) return _availableChannels.value = result - // Recompute teleported status based on current location vs selected channel val selectedChannelValue = _selectedChannel.value - when (selectedChannelValue) { - is ChannelID.Mesh -> { - _teleported.value = false - } - is ChannelID.Location -> { - val currentGeohash = Geohash.encode( - latitude = location.latitude, - longitude = location.longitude, - precision = selectedChannelValue.channel.level.precision - ) - val isTeleported = currentGeohash != selectedChannelValue.channel.geohash - _teleported.value = isTeleported - } + if (selectedChannelValue is ChannelID.Location && + selectedLocationSource == LocationSelectionSource.NEARBY + ) { + val currentGeohash = Geohash.encode( + latitude = location.latitude, + longitude = location.longitude, + precision = selectedChannelValue.channel.level.precision + ) + _teleported.value = currentGeohash != selectedChannelValue.channel.geohash + } else if (selectedChannelValue is ChannelID.Mesh) { + _teleported.value = false } } - private fun reverseGeocodeIfNeeded(location: Location) { - // Cancel any pending geocoding job to avoid race conditions + private fun reverseGeocodeIfNeeded(location: Location, token: Long) { + if (!canUseLiveLocation(token)) return geocodingJob?.cancel() geocodingJob = scope.launch(Dispatchers.IO) { try { - val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1) + if (!canUseLiveLocation(token)) return@launch + val addresses = geocoderProvider.getFromLocation( + location.latitude, + location.longitude, + 1, + liveLocationToken = token + ) - if (!isActive) return@launch + if (!isActive || !canUseLiveLocation(token)) return@launch if (addresses.isNotEmpty()) { val address = addresses[0] val names = namesByLevel(address) - _locationNames.value = names + LiveLocationPrivacyGate.runIfAllowed(token) { + if (_systemLocationEnabled.value && hasRuntimeLocationPermission()) { + _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}") + Log.e(TAG, "Reverse geocoding failed") } } } @@ -445,7 +554,10 @@ class LocationChannelManager private constructor(private val context: Context) { /** * Save current channel selection to persistent storage */ - private fun saveChannelSelection(channel: ChannelID) { + private fun saveChannelSelection( + channel: ChannelID, + source: LocationSelectionSource? + ) { try { val channelData = when (channel) { is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true)) @@ -453,13 +565,14 @@ class LocationChannelManager private constructor(private val context: Context) { PersistedChannel( mesh = false, level = channel.channel.level.name, - geohash = channel.channel.geohash + geohash = channel.channel.geohash, + source = source?.name ) ) } dataManager?.saveLastGeohashChannel(channelData) } catch (e: Exception) { - Log.e(TAG, "Failed to save channel selection: ${e.message}") + Log.e(TAG, "Failed to save channel selection") } } @@ -472,27 +585,44 @@ class LocationChannelManager private constructor(private val context: Context) { if (!channelData.isNullOrBlank()) { val persisted = gson.fromJson(channelData, PersistedChannel::class.java) val channel = persisted?.toChannel() - if (channel != null) { + val source = persisted?.selectionSource() + val canRestore = channel !is ChannelID.Location || + source == LocationSelectionSource.MANUAL || + (LiveLocationPrivacyGate.isEnabled && + _systemLocationEnabled.value && + _permissionState.value == PermissionState.AUTHORIZED) + + if (channel != null && canRestore) { _selectedChannel.value = channel + selectedLocationSource = if (channel is ChannelID.Location) source else null + _teleported.value = channel is ChannelID.Location && + source == LocationSelectionSource.MANUAL } else { _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null + _teleported.value = false + saveChannelSelection(ChannelID.Mesh, source = null) } } else { _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null } } catch (e: JsonSyntaxException) { - Log.e(TAG, "Failed to parse persisted channel data: ${e.message}") + Log.e(TAG, "Failed to parse persisted channel data") _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null } catch (e: Exception) { - Log.e(TAG, "Failed to load persisted channel: ${e.message}") + Log.e(TAG, "Failed to load persisted channel") _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null } } data class PersistedChannel( val mesh: Boolean, val level: String? = null, - val geohash: String? = null + val geohash: String? = null, + val source: String? = null ) { fun toChannel(): ChannelID? { return if (mesh) { @@ -503,6 +633,13 @@ class LocationChannelManager private constructor(private val context: Context) { ChannelID.Location.fromPersisted(levelName, gh) } } + + fun selectionSource(): LocationSelectionSource? { + if (mesh) return null + return source?.let { + runCatching { LocationSelectionSource.valueOf(it) }.getOrNull() + } ?: LocationSelectionSource.NEARBY + } } /** @@ -511,6 +648,7 @@ class LocationChannelManager private constructor(private val context: Context) { fun clearPersistedChannel() { dataManager?.clearLastGeohashChannel() _selectedChannel.value = ChannelID.Mesh + selectedLocationSource = null _teleported.value = false } @@ -523,7 +661,7 @@ class LocationChannelManager private constructor(private val context: Context) { try { dataManager?.saveLocationServicesEnabled(enabled) } catch (e: Exception) { - Log.e(TAG, "Failed to save location services state: ${e.message}") + Log.e(TAG, "Failed to save location services state") } } @@ -533,10 +671,10 @@ class LocationChannelManager private constructor(private val context: Context) { private fun loadLocationServicesState() { try { val enabled = dataManager?.isLocationServicesEnabled() ?: false - _locationServicesEnabled.value = enabled + LiveLocationPrivacyGate.update(enabled) } catch (e: Exception) { - Log.e(TAG, "Failed to load location services state: ${e.message}") - _locationServicesEnabled.value = false + Log.e(TAG, "Failed to load location services state") + LiveLocationPrivacyGate.update(false) } } diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt index 0ead523d..f46837bd 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationProvider.kt @@ -6,7 +6,7 @@ import android.location.Location * Abstraction for location providers to support both * System (LocationManager) and Google Play Services (FusedLocationProvider). */ -interface LocationProvider { +internal interface LocationProvider { /** * Get the last known location from cache. * @param callback Called with the location or null if not found/error. diff --git a/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt index 587d2970..11be053e 100644 --- a/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/OpenStreetMapGeocoderProvider.kt @@ -4,53 +4,79 @@ import android.location.Address import android.util.Log import com.bitchat.android.net.OkHttpProvider import com.google.gson.Gson +import java.io.IOException +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback import okhttp3.Request +import okhttp3.Response import java.util.Locale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlin.coroutines.resume 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) { + override suspend fun getFromLocation( + latitude: Double, + longitude: Double, + maxResults: Int, + liveLocationToken: Long? + ): List
{ + return suspendCancellableCoroutine { continuation -> 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" + val request = Request.Builder() + .url(url) + .header("User-Agent", userAgent) + .build() + val call = OkHttpProvider.httpClient().newCall(request) - 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
() + continuation.invokeOnCancellation { call.cancel() } + val enqueueRequest = { + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) { + Log.w(TAG, "OSM geocoding request failed") + continuation.resume(emptyList()) + } } - val body = response.body?.string() - response.close() + override fun onResponse(call: Call, response: Response) { + val addresses = response.use { + if (!it.isSuccessful) { + Log.w(TAG, "OSM geocoding request returned ${it.code}") + return@use emptyList() + } - if (body.isNullOrEmpty()) return@withContext emptyList
() + val body = it.body?.string() + if (body.isNullOrEmpty()) return@use 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
() + runCatching { + val osmResponse = gson.fromJson(body, OsmResponse::class.java) + if (osmResponse?.address == null) emptyList() + else listOf(mapToAddress(osmResponse, latitude, longitude)) + }.getOrElse { + Log.w(TAG, "OSM geocoding response could not be parsed") + emptyList() + } + } + if (continuation.isActive) continuation.resume(addresses) } - } catch (e: Exception) { - Log.e(TAG, "OSM Geocoding failed", e) - emptyList
() + }) + } + val started = if (liveLocationToken == null) { + enqueueRequest() + true + } else { + LiveLocationPrivacyGate.runIfAllowed( + liveLocationToken, + enqueueRequest + ) + } + if (!started && continuation.isActive) { + continuation.resume(emptyList()) } } } diff --git a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt index 61c9d267..80bbbe4a 100644 --- a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt @@ -9,10 +9,11 @@ import android.location.LocationListener import android.location.LocationManager import android.os.Build import android.os.Bundle +import android.os.CancellationSignal import android.util.Log import androidx.core.app.ActivityCompat -class SystemLocationProvider(private val context: Context) : LocationProvider { +internal class SystemLocationProvider(private val context: Context) : LocationProvider { companion object { private const val TAG = "SystemLocationProvider" @@ -25,10 +26,13 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>() private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>() private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>() + private val activeOneShotCancellationSignals = mutableMapOf<(Location?) -> Unit, CancellationSignal>() private fun hasLocationPermission(): Boolean { - return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + return LiveLocationPrivacyGate.isEnabled && + (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + ) } @SuppressLint("MissingPermission") @@ -49,9 +53,9 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { } } } - callback(bestLocation) + callback(bestLocation.takeIf { LiveLocationPrivacyGate.isEnabled }) } catch (e: Exception) { - Log.e(TAG, "Error getting last known location: ${e.message}") + Log.e(TAG, "Error getting last-known location") callback(null) } } @@ -76,12 +80,27 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { Log.d(TAG, "Requesting fresh location from $provider") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - locationManager.getCurrentLocation( - provider, - null, - context.mainExecutor - ) { location -> - callback(location) + val cancellationSignal = CancellationSignal() + synchronized(activeOneShotCancellationSignals) { + activeOneShotCancellationSignals[callback] = cancellationSignal + } + try { + locationManager.getCurrentLocation( + provider, + cancellationSignal, + context.mainExecutor + ) { location -> + synchronized(activeOneShotCancellationSignals) { + activeOneShotCancellationSignals.remove(callback) + } + callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) + } + } catch (e: Exception) { + synchronized(activeOneShotCancellationSignals) { + activeOneShotCancellationSignals.remove(callback) + } + cancellationSignal.cancel() + throw e } } else { // For older versions, use requestSingleUpdate with timeout mechanism @@ -94,7 +113,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { try { locationManager.removeUpdates(listener) } catch (e: Exception) { - Log.e(TAG, "Error removing timed out listener: ${e.message}") + Log.e(TAG, "Error removing timed-out listener") } } } @@ -113,9 +132,9 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { try { locationManager.removeUpdates(this) } catch (e: Exception) { - Log.e(TAG, "Error removing updates in callback: ${e.message}") + Log.e(TAG, "Error removing updates in callback") } - callback(location) + callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) } override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} override fun onProviderEnabled(provider: String) {} @@ -140,7 +159,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { callback(null) } } catch (e: Exception) { - Log.e(TAG, "Error requesting fresh location: ${e.message}") + Log.e(TAG, "Error requesting fresh location") callback(null) } } @@ -156,7 +175,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { try { val listener = object : LocationListener { override fun onLocationChanged(location: Location) { - callback(location) + if (LiveLocationPrivacyGate.isEnabled) callback(location) } override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} override fun onProviderEnabled(provider: String) {} @@ -189,7 +208,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { } } catch (e: Exception) { - Log.e(TAG, "Error requesting location updates: ${e.message}") + Log.e(TAG, "Error requesting location updates") } } @@ -204,7 +223,7 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { Log.d(TAG, "Removed location updates") } } catch (e: Exception) { - Log.e(TAG, "Error removing updates: ${e.message}") + Log.e(TAG, "Error removing updates") } } @@ -230,9 +249,13 @@ class SystemLocationProvider(private val context: Context) : LocationProvider { } activeOneShotRunnables.clear() } + synchronized(activeOneShotCancellationSignals) { + activeOneShotCancellationSignals.values.forEach { it.cancel() } + activeOneShotCancellationSignals.clear() + } Log.d(TAG, "Cancelled all system location requests") } catch (e: Exception) { - Log.e(TAG, "Error cancelling system provider: ${e.message}") + Log.e(TAG, "Error cancelling system provider") } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt index bed29902..d725f3e1 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt @@ -28,25 +28,33 @@ object LocationNotesInitializer { return@initialize id // Return subscription ID even on error } - Log.d(TAG, "📍 Location Notes subscribing to geohash: $geohashFromFilter") - + val token = com.bitchat.android.geohash.LiveLocationPrivacyGate + .captureToken() ?: return@initialize id NostrRelayManager.getInstance(context).subscribeForGeohash( geohash = geohashFromFilter, filter = filter, id = id, handler = handler, includeDefaults = true, - nRelays = 5 + nRelays = 5, + liveLocationToken = token ) }, unsubscribe = { id -> NostrRelayManager.getInstance(context).unsubscribe(id) }, - sendEvent = { event, relayUrls -> + sendEvent = { event, relayUrls, token -> if (relayUrls != null) { - NostrRelayManager.getInstance(context).sendEvent(event, relayUrls) + NostrRelayManager.getInstance(context).sendEvent( + event, + relayUrls, + liveLocationToken = token + ) } else { - NostrRelayManager.getInstance(context).sendEvent(event) + NostrRelayManager.getInstance(context).sendEvent( + event, + liveLocationToken = token + ) } }, deriveIdentity = { geohash -> diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index 2fa962ef..1a436f2d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -2,6 +2,7 @@ package com.bitchat.android.nostr import android.util.Log import androidx.annotation.MainThread +import com.bitchat.android.geohash.LiveLocationPrivacyGate import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -89,13 +90,18 @@ class LocationNotesManager private constructor() { private var relayLookup: (() -> NostrRelayManager)? = null private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null private var unsubscribeFunc: ((String) -> Unit)? = null - private var sendEventFunc: ((NostrEvent, List?) -> Unit)? = null + private var sendEventFunc: ((NostrEvent, List?, Long) -> Unit)? = null private var deriveIdentityFunc: ((String) -> NostrIdentity)? = null // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var liveLocationToken: Long? = null private var subscribeRetryJob: Job? = null private var initialLoadJob: Job? = null + + init { + LiveLocationPrivacyGate.addRevocationListener(::stop) + } /** * Initialize dependencies @@ -104,7 +110,7 @@ class LocationNotesManager private constructor() { relayManager: () -> NostrRelayManager, subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String, unsubscribe: (String) -> Unit, - sendEvent: (NostrEvent, List?) -> Unit, + sendEvent: (NostrEvent, List?, Long) -> Unit, deriveIdentity: (String) -> NostrIdentity ) { this.relayLookup = relayManager @@ -119,23 +125,28 @@ class LocationNotesManager private constructor() { * iOS: Validates building-level precision (8 characters) */ fun setGeohash(newGeohash: String) { + val token = LiveLocationPrivacyGate.captureToken() ?: run { + stop() + return + } val normalized = newGeohash.lowercase() - if (_geohash.value == normalized) { - Log.d(TAG, "Geohash unchanged, skipping: $normalized") + if (_geohash.value == normalized && + liveLocationToken?.let(LiveLocationPrivacyGate::accepts) == true + ) { return } // Validate geohash (building-level precision: 8 chars) - matches iOS if (!isValidBuildingGeohash(normalized)) { - Log.w(TAG, "LocationNotesManager: rejecting invalid geohash '$normalized' (expected 8 valid base32 chars)") + Log.w(TAG, "LocationNotesManager rejected an invalid building geohash") return } - - Log.d(TAG, "Setting geohash: $normalized") - + // Cancel existing subscription cancel() + if (!LiveLocationPrivacyGate.accepts(token)) return + liveLocationToken = token // Set loading state before clearing to prevent empty state flicker (iOS pattern) _state.value = State.LOADING @@ -154,7 +165,7 @@ class LocationNotesManager private constructor() { subscribedGeohashes = (neighbors + normalized).toSet() // Start new subscriptions for all cells - subscribeAll() + subscribeAll(token) } /** @@ -170,16 +181,20 @@ class LocationNotesManager private constructor() { * Refresh notes for current geohash */ fun refresh() { + val token = LiveLocationPrivacyGate.captureToken() ?: run { + stop() + return + } val currentGeohash = _geohash.value if (currentGeohash == null) { Log.w(TAG, "Cannot refresh - no geohash set") return } - Log.d(TAG, "Refreshing notes for geohash: $currentGeohash") - // Cancel and restart subscriptions for current ±1 set cancel() + if (!LiveLocationPrivacyGate.accepts(token)) return + liveLocationToken = token _notes.value = emptyList() noteIDs.clear() _initialLoadComplete.value = false @@ -188,13 +203,17 @@ class LocationNotesManager private constructor() { com.bitchat.android.geohash.Geohash.neighborsSamePrecision(currentGeohash) } catch (_: Exception) { emptySet() } subscribedGeohashes = (neighbors + currentGeohash).toSet() - subscribeAll() + subscribeAll(token) } /** * Send a new location note */ fun send(content: String, nickname: String?) { + val token = LiveLocationPrivacyGate.captureToken() ?: run { + stop() + return + } val currentGeohash = _geohash.value if (currentGeohash == null) { Log.w(TAG, "Cannot send note - no geohash set") @@ -209,16 +228,22 @@ class LocationNotesManager private constructor() { // CRITICAL FIX: Get geo-specific relays for sending (matching iOS pattern) // iOS: let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) - val relays = try { - com.bitchat.android.nostr.RelayDirectory.closestRelaysForGeohash(currentGeohash, 5) + var relays: List = emptyList() + try { + LiveLocationPrivacyGate.runIfAllowed(token) { + relays = RelayDirectory.closestRelaysForGeohash(currentGeohash, 5) + } } catch (e: Exception) { - Log.e(TAG, "Failed to lookup relays for geohash $currentGeohash: ${e.message}") - emptyList() + Log.e(TAG, "Failed to look up location-note relays") + } + if (!LiveLocationPrivacyGate.accepts(token)) { + stop() + return } // Check if we have relays (iOS pattern: guard !relays.isEmpty()) if (relays.isEmpty()) { - Log.w(TAG, "Send blocked - no geo relays for geohash: $currentGeohash") + Log.w(TAG, "Location-note send blocked because no relays are available") _state.value = State.NO_RELAYS _errorMessage.value = "No relays available" return @@ -231,34 +256,40 @@ class LocationNotesManager private constructor() { return } - Log.d(TAG, "Sending note to geohash: $currentGeohash via ${relays.size} geo relays") - scope.launch { try { - val identity = withContext(Dispatchers.IO) { - deriveIdentity(currentGeohash) + var identity: NostrIdentity? = null + val identityPrepared = withContext(Dispatchers.IO) { + LiveLocationPrivacyGate.runIfAllowed(token) { + identity = deriveIdentity(currentGeohash) + } } - - val event = withContext(Dispatchers.IO) { + val preparedIdentity = identity + if (!identityPrepared || preparedIdentity == null || + !LiveLocationPrivacyGate.accepts(token) + ) return@launch + + val preparedEvent = withContext(Dispatchers.IO) { NostrProtocol.createGeohashTextNote( - content = trimmed, - geohash = currentGeohash, - senderIdentity = identity, - nickname = nickname - ) + content = trimmed, + geohash = currentGeohash, + senderIdentity = preparedIdentity, + nickname = nickname + ) } - + if (!LiveLocationPrivacyGate.accepts(token)) return@launch + // Optimistic local echo - add note immediately to UI val localNote = Note( - id = event.id, - pubkey = event.pubkey, + id = preparedEvent.id, + pubkey = preparedEvent.pubkey, content = trimmed, - createdAt = event.createdAt, + createdAt = preparedEvent.createdAt, nickname = nickname ) - if (!noteIDs.contains(event.id)) { - noteIDs.add(event.id) + if (!noteIDs.contains(preparedEvent.id)) { + noteIDs.add(preparedEvent.id) val currentNotes = _notes.value ?: emptyList() _notes.value = (currentNotes + localNote).sortedByDescending { it.createdAt } @@ -270,11 +301,12 @@ class LocationNotesManager private constructor() { // CRITICAL FIX: Send to geo-specific relays (matching iOS pattern) // iOS: dependencies.sendEvent(event, relays) - withContext(Dispatchers.IO) { - sendEventFunc?.invoke(event, relays) + val sent = withContext(Dispatchers.IO) { + LiveLocationPrivacyGate.runIfAllowed(token) { + sendEventFunc?.invoke(preparedEvent, relays, token) + } } - - Log.d(TAG, "✅ Note sent successfully to ${relays.size} geo relays: ${event.id.take(16)}...") + if (!sent) return@launch // Clear any error messages on successful send _errorMessage.value = null @@ -290,12 +322,16 @@ class LocationNotesManager private constructor() { /** * Subscribe to location notes for current geohash */ - private fun subscribeAll() { + private fun subscribeAll(token: Long) { subscribeRetryJob?.cancel() subscribeRetryJob = null initialLoadJob?.cancel() initialLoadJob = null + if (!LiveLocationPrivacyGate.accepts(token)) { + stop() + return + } val currentGeohash = _geohash.value if (currentGeohash == null) { Log.w(TAG, "Cannot subscribe - no geohash set") @@ -310,17 +346,20 @@ class LocationNotesManager private constructor() { // Retry a few times in case initialization is racing the sheet open subscribeRetryJob = scope.launch { var attempts = 0 - while (attempts < 10 && subscribeFunc == null) { + while (attempts < 10 && + subscribeFunc == null && + LiveLocationPrivacyGate.accepts(token) + ) { delay(300) attempts++ } val subNow = subscribeFunc - if (subNow != null) { + if (subNow != null && LiveLocationPrivacyGate.accepts(token)) { // Try again now that dependencies are ready - subscribeAll() + subscribeAll(token) } else { // Give UI a chance to show empty state rather than spinner forever - if (!_initialLoadComplete.value!!) { + if (!_initialLoadComplete.value) { _initialLoadComplete.value = true _state.value = State.READY } @@ -333,28 +372,33 @@ class LocationNotesManager private constructor() { // Subscribe for each geohash in the ±1 set subscribedGeohashes.forEach { gh -> + if (!LiveLocationPrivacyGate.accepts(token)) return val filter = NostrFilter.geohashNotes( geohash = gh, since = null, limit = 200 ) val subId = "location-notes-$gh" - Log.d(TAG, "📡 Subscribing to location notes: $subId") try { - val id = subscribe(filter, subId) { event -> handleEvent(event) } - subscriptionIDs[gh] = id + var id: String? = null + LiveLocationPrivacyGate.runIfAllowed(token) { + id = subscribe(filter, subId) { event -> handleEvent(event) } + } + id?.let { subscriptionIDs[gh] = it } } catch (e: Exception) { - Log.e(TAG, "Failed to subscribe for $gh: ${e.message}") + Log.e(TAG, "Failed to subscribe to location notes") } } // Mark initial load complete after brief delay to allow relay responses initialLoadJob = scope.launch { delay(2000) // Wait 2 seconds for initial batch - if (_geohash.value == currentGeohash && !_initialLoadComplete.value) { + if (_geohash.value == currentGeohash && + LiveLocationPrivacyGate.accepts(token) && + !_initialLoadComplete.value + ) { _initialLoadComplete.value = true _state.value = State.READY - Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)") } } } @@ -363,6 +407,9 @@ class LocationNotesManager private constructor() { * Handle incoming event from subscription */ private fun handleEvent(event: NostrEvent) { + val token = liveLocationToken + if (token == null || !LiveLocationPrivacyGate.accepts(token)) return + // Validate event if (event.kind != NostrKind.TEXT_NOTE) { Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}") @@ -379,7 +426,6 @@ class LocationNotesManager private constructor() { // Check if matches current geohash val eventGeohash = geohashTag[1] if (!subscribedGeohashes.contains(eventGeohash)) { - Log.v(TAG, "Ignoring event for non-subscribed geohash: $eventGeohash") return } @@ -406,8 +452,6 @@ class LocationNotesManager private constructor() { val currentNotes = _notes.value ?: emptyList() _notes.value = (currentNotes + note).sortedByDescending { it.createdAt } - Log.d(TAG, "Added note from ${note.displayName}") - // Trim if exceeds max if (noteIDs.size > MAX_NOTES_IN_MEMORY) { trimOldestNotes() @@ -456,7 +500,6 @@ class LocationNotesManager private constructor() { if (subscriptionIDs.isNotEmpty()) { subscriptionIDs.values.forEach { subId -> try { - Log.d(TAG, "🚫 Canceling subscription: $subId") unsubscribeFunc?.invoke(subId) } catch (_: Exception) { } } @@ -473,9 +516,10 @@ class LocationNotesManager private constructor() { */ fun stop() { cancel() + liveLocationToken = null + _geohash.value = null _notes.value = emptyList() noteIDs.clear() - _geohash.value = null _initialLoadComplete.value = false _errorMessage.value = null } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index a4803157..c1488037 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -180,7 +180,7 @@ class NostrClient private constructor(private val context: Context) { relayManager.sendEvent(event) - Log.i(TAG, "📤 Sent geohash message to #$geohash") + Log.i(TAG, "📤 Sent geohash message") onSuccess?.invoke() } catch (e: Exception) { @@ -209,7 +209,7 @@ class NostrClient private constructor(private val context: Context) { } }) - Log.i(TAG, "🌍 Subscribed to geohash channel: #$geohash") + Log.i(TAG, "🌍 Subscribed to geohash channel") } /** @@ -217,7 +217,7 @@ class NostrClient private constructor(private val context: Context) { */ fun unsubscribeFromGeohash(geohash: String) { relayManager.unsubscribe("geohash-$geohash") - Log.i(TAG, "Unsubscribed from geohash channel: #$geohash") + Log.i(TAG, "Unsubscribed from geohash channel") } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index 7b8552be..665bfd50 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -6,6 +6,7 @@ import com.bitchat.android.favorites.FavoritesPersistenceService import com.bitchat.android.identity.SecureIdentityStateManager import java.security.MessageDigest import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap /** * Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging @@ -100,7 +101,7 @@ object NostrIdentityBridge { private const val DEVICE_SEED_KEY = "nostr_device_seed" // Cache for derived geohash identities to avoid repeated crypto operations - private val geohashIdentityCache = mutableMapOf() + private val geohashIdentityCache = ConcurrentHashMap() /** * Get or create the current Nostr identity @@ -157,7 +158,7 @@ object NostrIdentityBridge { // Cache the result for future UI responsiveness geohashIdentityCache[forGeohash] = identity - Log.d(TAG, "Derived geohash identity for $forGeohash (iteration $i)") + Log.d(TAG, "Derived geohash identity") return identity } } @@ -172,7 +173,7 @@ object NostrIdentityBridge { // Cache the fallback result too geohashIdentityCache[forGeohash] = fallbackIdentity - Log.d(TAG, "Used fallback identity derivation for $forGeohash") + Log.d(TAG, "Used fallback geohash identity derivation") return fallbackIdentity } @@ -219,6 +220,10 @@ object NostrIdentityBridge { Log.e(TAG, "Failed to clear Nostr data: ${e.message}") } } + + fun clearGeohashIdentityCache(geohashes: Collection) { + geohashes.forEach(geohashIdentityCache::remove) + } // MARK: - Private Methods diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index f50d65cc..c707c88c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -1,6 +1,7 @@ package com.bitchat.android.nostr import android.util.Log +import com.bitchat.android.geohash.LiveLocationPrivacyGate import com.google.gson.Gson import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -97,14 +98,20 @@ class NostrRelayManager private constructor() { val handler: (NostrEvent) -> Unit, val targetRelayUrls: Set? = null, // null means all relays val createdAt: Long = System.currentTimeMillis(), - val originGeohash: String? = null // used for logging and grouping + val liveLocationToken: Long? = null ) // Event deduplication system private val eventDeduplicator = NostrEventDeduplicator.getInstance() // Message queue for reliability - private val messageQueue = mutableListOf>>() + private data class QueuedEvent( + val event: NostrEvent, + val targetRelays: List, + val liveLocationToken: Long? = null + ) + + private val messageQueue = mutableListOf() private val messageQueueLock = Any() // Coroutine scope for background operations @@ -122,27 +129,49 @@ class NostrRelayManager private constructor() { // Per-geohash relay selection private val geohashToRelays = ConcurrentHashMap>() // geohash -> relay URLs + private val liveGeohashTokens = ConcurrentHashMap() + private val liveLocationRelayTokens = ConcurrentHashMap() + private val nonLiveRelayUrls = ConcurrentHashMap.newKeySet() + private val liveLocationConnectionJobs = ConcurrentHashMap.newKeySet() // --- Public API for geohash-specific operation --- /** * Compute and connect to relays for a given geohash (nearest + optional defaults), cache the mapping. */ - fun ensureGeohashRelaysConnected(geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false) { + fun ensureGeohashRelaysConnected( + geohash: String, + nRelays: Int = 5, + includeDefaults: Boolean = false, + liveLocationToken: Long? = null + ) { + if (!isNetworkActionAllowed(liveLocationToken)) return try { val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays) val selected = if (includeDefaults) { (nearest + Companion.defaultRelays()).toSet() } else nearest.toSet() if (selected.isEmpty()) { - Log.w(TAG, "No relays selected for geohash=$geohash") + Log.w(TAG, "No relays selected for a geohash") return } - geohashToRelays[geohash] = selected - Log.d(TAG, "Geohash $geohash using ${selected.size} relays") - ensureConnectionsFor(selected) + runNetworkAction(liveLocationToken) { + geohashToRelays[geohash] = selected + if (liveLocationToken == null) { + liveGeohashTokens.remove(geohash) + nonLiveRelayUrls.addAll(selected) + } else { + liveGeohashTokens[geohash] = liveLocationToken + selected.forEach { relayUrl -> + if (relayUrl !in nonLiveRelayUrls) { + liveLocationRelayTokens[relayUrl] = liveLocationToken + } + } + } + ensureConnectionsFor(selected, liveLocationToken) + } } catch (e: Exception) { - Log.e(TAG, "Failed to ensure relays for $geohash: ${e.message}") + Log.e(TAG, "Failed to ensure geohash relays") } } @@ -162,40 +191,107 @@ class NostrRelayManager private constructor() { id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, includeDefaults: Boolean = false, - nRelays: Int = 5 + nRelays: Int = 5, + liveLocationToken: Long? = null ): String { - ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults) + if (!isNetworkActionAllowed(liveLocationToken)) return id + ensureGeohashRelaysConnected( + geohash, + nRelays, + includeDefaults, + liveLocationToken + ) + if (!isNetworkActionAllowed(liveLocationToken)) return id val relayUrls = getRelaysForGeohash(geohash) return subscribe( filter = filter, id = id, handler = handler, - targetRelayUrls = relayUrls - ).also { - // update origin geohash for this subscription - activeSubscriptions[it]?.let { sub -> - activeSubscriptions[it] = sub.copy(originGeohash = geohash) - } - } + targetRelayUrls = relayUrls, + liveLocationToken = liveLocationToken + ) } /** * Send an event specifically to a geohash's relays (+ optional defaults). */ - fun sendEventToGeohash(event: NostrEvent, geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5) { - ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults) + fun sendEventToGeohash( + event: NostrEvent, + geohash: String, + includeDefaults: Boolean = false, + nRelays: Int = 5, + liveLocationToken: Long? = null + ) { + if (!isNetworkActionAllowed(liveLocationToken)) return + ensureGeohashRelaysConnected( + geohash, + nRelays, + includeDefaults, + liveLocationToken + ) + if (!isNetworkActionAllowed(liveLocationToken)) return val relayUrls = getRelaysForGeohash(geohash) if (relayUrls.isEmpty()) { - Log.w(TAG, "No target relays to send event for geohash=$geohash; falling back to defaults") - sendEvent(event, Companion.defaultRelays()) + Log.w(TAG, "No target relays for geohash event; falling back to defaults") + sendEvent(event, Companion.defaultRelays(), liveLocationToken) return } - sendEvent(event, relayUrls) + sendEvent(event, relayUrls, liveLocationToken) } // --- Internal helpers --- - private fun ensureConnectionsFor(relayUrls: Set) { + private fun isNetworkActionAllowed(liveLocationToken: Long?): Boolean = + liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken) + + private fun runNetworkAction( + liveLocationToken: Long?, + action: () -> Unit + ): Boolean = if (liveLocationToken == null) { + action() + true + } else { + LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, action) + } + + private fun revokeLiveLocationAccess() { + liveLocationConnectionJobs.forEach(Job::cancel) + liveLocationConnectionJobs.clear() + + val liveSubscriptionIds = activeSubscriptions.values + .filter { it.liveLocationToken != null } + .mapTo(mutableSetOf()) { it.id } + liveSubscriptionIds.forEach { id -> + activeSubscriptions.remove(id) + messageHandlers.remove(id) + } + subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds } + + synchronized(messageQueueLock) { + messageQueue.removeAll { it.liveLocationToken != null } + } + + liveGeohashTokens.keys.forEach(geohashToRelays::remove) + liveGeohashTokens.clear() + + val liveOnlyRelayUrls = liveLocationRelayTokens.keys + .filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls } + liveOnlyRelayUrls.forEach { relayUrl -> + connections.remove(relayUrl)?.cancel() + } + synchronized(relaysList) { + relaysList.removeAll { it.url in liveOnlyRelayUrls } + } + liveLocationRelayTokens.clear() + updateRelaysList() + updateConnectionStatus() + } + + private fun ensureConnectionsFor( + relayUrls: Set, + liveLocationToken: Long? = null + ) { + if (!isNetworkActionAllowed(liveLocationToken)) return // Ensure relays are tracked for UI/status relayUrls.forEach { url -> if (relaysList.none { it.url == url }) { @@ -204,15 +300,22 @@ class NostrRelayManager private constructor() { } updateRelaysList() - scope.launch { + val job = scope.launch { + if (!isNetworkActionAllowed(liveLocationToken)) return@launch relayUrls.forEach { relayUrl -> launch { - if (!connections.containsKey(relayUrl)) { - connectToRelay(relayUrl) + if (!connections.containsKey(relayUrl) && + isNetworkActionAllowed(liveLocationToken) + ) { + connectToRelay(relayUrl, liveLocationToken) } } } } + if (liveLocationToken != null) { + liveLocationConnectionJobs.add(job) + job.invokeOnCompletion { liveLocationConnectionJobs.remove(job) } + } } init { @@ -225,8 +328,10 @@ class NostrRelayManager private constructor() { "wss://nostr21.com" ) relaysList.addAll(defaultRelayUrls.map { Relay(it) }) + nonLiveRelayUrls.addAll(defaultRelayUrls) _relays.value = relaysList.toList() updateConnectionStatus() + LiveLocationPrivacyGate.addRevocationListener(::revokeLiveLocationAccess) } catch (e: Exception) { Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e) // Initialize with empty list as fallback @@ -239,12 +344,14 @@ class NostrRelayManager private constructor() { * Connect to all configured relays */ fun connect() { - Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays") - scope.launch { relaysList.forEach { relay -> launch { - connectToRelay(relay.url) + val liveToken = liveLocationRelayTokens[relay.url] + ?.takeIf { relay.url !in nonLiveRelayUrls } + if (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) { + connectToRelay(relay.url, liveToken) + } } } } @@ -257,8 +364,6 @@ class NostrRelayManager private constructor() { * Disconnect from all relays */ fun disconnect() { - Log.i(TAG, "Disconnecting from all Nostr relays") - // Stop subscription validation stopSubscriptionValidation() @@ -276,23 +381,28 @@ class NostrRelayManager private constructor() { /** * Send an event to specified relays (or all if none specified) */ - fun sendEvent(event: NostrEvent, relayUrls: List? = null) { + fun sendEvent( + event: NostrEvent, + relayUrls: List? = null, + liveLocationToken: Long? = null + ) { val targetRelays = relayUrls ?: relaysList.map { it.url } - - // Add to queue for reliability - synchronized(messageQueueLock) { - messageQueue.add(Pair(event, targetRelays)) - } - - // Attempt immediate send - scope.launch { - targetRelays.forEach { relayUrl -> - val webSocket = connections[relayUrl] - if (webSocket != null) { - sendToRelay(event, webSocket, relayUrl) + + val queued = runNetworkAction(liveLocationToken) { + synchronized(messageQueueLock) { + messageQueue.add(QueuedEvent(event, targetRelays, liveLocationToken)) + } + scope.launch { + if (!isNetworkActionAllowed(liveLocationToken)) return@launch + targetRelays.forEach { relayUrl -> + val webSocket = connections[relayUrl] + if (webSocket != null) { + sendToRelay(event, webSocket, relayUrl, liveLocationToken) + } } } } + if (!queued) return } /** @@ -303,21 +413,22 @@ class NostrRelayManager private constructor() { filter: NostrFilter, id: String = generateSubscriptionId(), handler: (NostrEvent) -> Unit, - targetRelayUrls: List? = null + targetRelayUrls: List? = null, + liveLocationToken: Long? = null ): String { - // Store subscription info for persistent tracking val subscriptionInfo = SubscriptionInfo( id = id, filter = filter, handler = handler, - targetRelayUrls = targetRelayUrls?.toSet() + targetRelayUrls = targetRelayUrls?.toSet(), + liveLocationToken = liveLocationToken ) - activeSubscriptions[id] = subscriptionInfo - messageHandlers[id] = handler - - // Send subscription to appropriate relays - sendSubscriptionToRelays(subscriptionInfo) + runNetworkAction(liveLocationToken) { + activeSubscriptions[id] = subscriptionInfo + messageHandlers[id] = handler + sendSubscriptionToRelays(subscriptionInfo) + } return id } @@ -326,32 +437,38 @@ class NostrRelayManager private constructor() { * Send a subscription to the appropriate relays */ private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) { + if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) val message = gson.toJson(request, NostrRequest::class.java) scope.launch { + if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList() targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] if (webSocket != null) { try { - val success = webSocket.send(message) + var success = false + runNetworkAction(subscriptionInfo.liveLocationToken) { + success = webSocket.send(message) + } if (success) { // Track subscription for this relay val currentSubs = subscriptions[relayUrl] ?: emptySet() subscriptions[relayUrl] = currentSubs + subscriptionInfo.id + } else { - Log.w(TAG, "Failed to send subscription to $relayUrl: WebSocket send failed") + Log.w(TAG, "Failed to send subscription: WebSocket send failed") } } catch (e: Exception) { - Log.e(TAG, "Failed to send subscription to $relayUrl: ${e.message}") + Log.e(TAG, "Failed to send subscription") } } } if (connections.isEmpty()) { - Log.w(TAG, "No relay connections available for subscription, will retry on reconnection") + Log.w(TAG, "⚠️ No relay connections available for subscription, will retry on reconnection") } } } @@ -365,7 +482,6 @@ class NostrRelayManager private constructor() { messageHandlers.remove(id) if (subscriptionInfo == null) { - Log.w(TAG, "Attempted to unsubscribe from unknown subscription: $id") return } @@ -373,14 +489,20 @@ class NostrRelayManager private constructor() { val message = gson.toJson(request, NostrRequest::class.java) scope.launch { + if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) { + subscriptions.replaceAll { _, ids -> ids - id } + return@launch + } connections.forEach { (relayUrl, webSocket) -> val currentSubs = subscriptions[relayUrl] if (currentSubs?.contains(id) == true) { try { - webSocket.send(message) + runNetworkAction(subscriptionInfo.liveLocationToken) { + webSocket.send(message) + } subscriptions[relayUrl] = currentSubs - id } catch (e: Exception) { - Log.e(TAG, "Failed to unsubscribe from $relayUrl: ${e.message}") + Log.e(TAG, "Failed to unsubscribe from relay") } } } @@ -392,6 +514,9 @@ class NostrRelayManager private constructor() { */ fun retryConnection(relayUrl: String) { val relay = relaysList.find { it.url == relayUrl } ?: return + val liveToken = liveLocationRelayTokens[relayUrl] + ?.takeIf { relayUrl !in nonLiveRelayUrls } + if (!isNetworkActionAllowed(liveToken)) return // Reset reconnection attempts relay.reconnectAttempts = 0 @@ -403,7 +528,7 @@ class NostrRelayManager private constructor() { // Attempt immediate reconnection scope.launch { - connectToRelay(relayUrl) + connectToRelay(relayUrl, liveToken) } } @@ -552,7 +677,7 @@ class NostrRelayManager private constructor() { try { val report = validateSubscriptionConsistency() if (!report.isConsistent && report.connectedRelayCount > 0) { - Log.w(TAG, "Subscription inconsistencies detected: ${report.inconsistencies}") + Log.w(TAG, "Nostr subscription inconsistencies detected") // Auto-repair: re-establish subscriptions for relays with missing ones connections.forEach { (relayUrl, webSocket) -> @@ -564,7 +689,7 @@ class NostrRelayManager private constructor() { val missingSubs = expectedSubs - currentSubs if (missingSubs.isNotEmpty()) { - Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl") + Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions") restoreSubscriptionsForRelay(relayUrl, webSocket) } } @@ -574,6 +699,7 @@ class NostrRelayManager private constructor() { } } } + } /** @@ -586,7 +712,13 @@ class NostrRelayManager private constructor() { // MARK: - Private Methods - private suspend fun connectToRelay(urlString: String) { + private suspend fun connectToRelay( + urlString: String, + liveLocationToken: Long? = null + ) { + val connectionToken = liveLocationToken + ?.takeIf { urlString !in nonLiveRelayUrls } + if (!isNetworkActionAllowed(connectionToken)) return // Skip if we already have a connection if (connections.containsKey(urlString)) { return @@ -597,31 +729,45 @@ class NostrRelayManager private constructor() { .url(urlString) .build() - val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString)) - connections[urlString] = webSocket + runNetworkAction(connectionToken) { + val webSocket = httpClient.newWebSocket( + request, + RelayWebSocketListener(urlString, connectionToken) + ) + connections[urlString] = webSocket + } } catch (e: Exception) { - Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}") - handleDisconnection(urlString, e) + Log.e(TAG, "Failed to create WebSocket connection") + handleDisconnection(urlString, e, liveLocationToken) } } - private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) { + private fun sendToRelay( + event: NostrEvent, + webSocket: WebSocket, + relayUrl: String, + liveLocationToken: Long? = null + ) { + if (!isNetworkActionAllowed(liveLocationToken)) return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) - val success = webSocket.send(message) + var success = false + runNetworkAction(liveLocationToken) { + success = webSocket.send(message) + } if (success) { // Update relay stats val relay = relaysList.find { it.url == relayUrl } relay?.messagesSent = (relay?.messagesSent ?: 0) + 1 updateRelaysList() } else { - Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed") + Log.e(TAG, "Failed to send event: WebSocket send failed") } } catch (e: Exception) { - Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}") + Log.e(TAG, "Failed to send event") } } @@ -629,7 +775,7 @@ class NostrRelayManager private constructor() { try { val jsonElement = JsonParser.parseString(message) if (!jsonElement.isJsonArray) { - Log.w(TAG, "Received non-array message from $relayUrl") + Log.w(TAG, "Received non-array message from relay") return } @@ -643,7 +789,10 @@ class NostrRelayManager private constructor() { updateRelaysList() // CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter - activeSubscriptions[response.subscriptionId]?.let { subInfo -> + val subscriptionInfo = activeSubscriptions[response.subscriptionId] + ?: return + if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return + subscriptionInfo.let { subInfo -> val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true } if (!matches) { // Do NOT call deduplicator here to allow the correct subscription to process it later @@ -657,12 +806,15 @@ class NostrRelayManager private constructor() { val handler = messageHandlers[response.subscriptionId] if (handler != null) { scope.launch(Dispatchers.Main) { - handler(event) + if (isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) { + handler(event) + } } } else { - Log.d(TAG, "No handler for subscription ${response.subscriptionId}") + Log.w(TAG, "⚠️ No handler for Nostr subscription") } } + } is NostrResponse.EndOfStoredEvents -> { @@ -673,29 +825,36 @@ class NostrRelayManager private constructor() { val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId) if (!response.accepted) { val level = if (wasGiftWrap) Log.WARN else Log.ERROR - Log.println(level, TAG, "Event rejected by relay $relayUrl: ${response.message ?: "no reason"}") + Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}") } } is NostrResponse.Notice -> { - Log.d(TAG, "Notice from $relayUrl: ${response.message}") + // No action needed } is NostrResponse.Unknown -> { - Log.d(TAG, "Unknown message type from $relayUrl") + // No action needed } } } catch (e: Exception) { - Log.e(TAG, "Failed to parse message from $relayUrl: ${e.message}") + Log.e(TAG, "Failed to parse relay message") } } - private fun handleDisconnection(relayUrl: String, error: Throwable) { + private fun handleDisconnection( + relayUrl: String, + error: Throwable, + liveLocationToken: Long? = null + ) { + val connectionToken = liveLocationToken + ?.takeIf { relayUrl !in nonLiveRelayUrls } connections.remove(relayUrl) // NOTE: Don't remove subscriptions here - keep them for restoration on reconnection // subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss updateRelayStatus(relayUrl, false, error) + if (!isNetworkActionAllowed(connectionToken)) return // Check if this is a DNS error val errorMessage = error.message?.lowercase() ?: "" @@ -705,7 +864,7 @@ class NostrRelayManager private constructor() { val relay = relaysList.find { it.url == relayUrl } if (relay?.lastError == null) { - Log.w(TAG, "Nostr relay DNS failure for $relayUrl - not retrying") + Log.w(TAG, "Nostr relay DNS failure; not retrying") } return } @@ -716,7 +875,7 @@ class NostrRelayManager private constructor() { // Stop attempting after max attempts if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - Log.w(TAG, "Max reconnection attempts ($MAX_RECONNECT_ATTEMPTS) reached for $relayUrl") + Log.w(TAG, "Max Nostr relay reconnection attempts reached") return } @@ -728,12 +887,14 @@ class NostrRelayManager private constructor() { relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval - Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})") + Log.d(TAG, "Scheduling Nostr relay reconnection") // Schedule reconnection scope.launch { delay(backoffInterval) - connectToRelay(relayUrl) + if (isNetworkActionAllowed(connectionToken)) { + connectToRelay(relayUrl, connectionToken) + } } } @@ -774,30 +935,34 @@ class NostrRelayManager private constructor() { private fun restoreSubscriptionsForRelay(relayUrl: String, webSocket: WebSocket) { val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo -> // Include subscription if it targets all relays or specifically targets this relay - subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl) + isNetworkActionAllowed(subscriptionInfo.liveLocationToken) && + (subscriptionInfo.targetRelayUrls == null || + subscriptionInfo.targetRelayUrls.contains(relayUrl)) } if (subscriptionsToRestore.isEmpty()) { return } - Log.d(TAG, "Restoring ${subscriptionsToRestore.size} subscriptions for relay: $relayUrl") - subscriptionsToRestore.forEach { subscriptionInfo -> try { val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter)) val message = gson.toJson(request, NostrRequest::class.java) - val success = webSocket.send(message) + var success = false + runNetworkAction(subscriptionInfo.liveLocationToken) { + success = webSocket.send(message) + } if (success) { // Track subscription for this relay val currentSubs = subscriptions[relayUrl] ?: emptySet() subscriptions[relayUrl] = currentSubs + subscriptionInfo.id + } else { - Log.w(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: WebSocket send failed") + Log.w(TAG, "Failed to restore subscription: WebSocket send failed") } } catch (e: Exception) { - Log.e(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: ${e.message}") + Log.e(TAG, "Failed to restore subscription") } } } @@ -805,10 +970,17 @@ class NostrRelayManager private constructor() { /** * WebSocket listener for relay connections */ - private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() { + private inner class RelayWebSocketListener( + private val relayUrl: String, + private val liveLocationToken: Long? + ) : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - Log.i(TAG, "Connected to Nostr relay: $relayUrl") + if (!isNetworkActionAllowed(liveLocationToken)) { + connections.remove(relayUrl) + webSocket.cancel() + return + } updateRelayStatus(relayUrl, true) // Restore all active subscriptions for this relay @@ -818,9 +990,16 @@ class NostrRelayManager private constructor() { synchronized(messageQueueLock) { val iterator = messageQueue.iterator() while (iterator.hasNext()) { - val (event, targetRelays) = iterator.next() - if (relayUrl in targetRelays) { - sendToRelay(event, webSocket, relayUrl) + val queued = iterator.next() + if (relayUrl in queued.targetRelays && + isNetworkActionAllowed(queued.liveLocationToken) + ) { + sendToRelay( + queued.event, + webSocket, + relayUrl, + queued.liveLocationToken + ) } } } @@ -835,14 +1014,13 @@ class NostrRelayManager private constructor() { } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.i(TAG, "Disconnected from Nostr relay $relayUrl: $code $reason") val error = Exception("WebSocket closed: $code $reason") - handleDisconnection(relayUrl, error) + handleDisconnection(relayUrl, error, liveLocationToken) } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}") - handleDisconnection(relayUrl, t) + Log.e(TAG, "Nostr WebSocket failure") + handleDisconnection(relayUrl, t, liveLocationToken) } } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt index 1a8514ab..4b8365a1 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -2,6 +2,7 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log +import com.bitchat.android.geohash.LiveLocationPrivacyGate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -28,18 +29,54 @@ class NostrSubscriptionManager( } /** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */ - fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { + fun subscribeGeohashMessages( + geohash: String, + sinceMs: Long, + limit: Int, + id: String, + handler: (NostrEvent) -> Unit, + liveLocationToken: Long? = null + ) { scope.launch { + if (liveLocationToken != null && + !LiveLocationPrivacyGate.accepts(liveLocationToken) + ) return@launch val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit) - relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + liveLocationToken = liveLocationToken + ) } } /** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */ - fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { + fun subscribeGeohashPresence( + geohash: String, + sinceMs: Long, + limit: Int, + id: String, + handler: (NostrEvent) -> Unit, + liveLocationToken: Long? = null + ) { scope.launch { + if (liveLocationToken != null && + !LiveLocationPrivacyGate.accepts(liveLocationToken) + ) return@launch val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit) - relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + relayManager.subscribeForGeohash( + geohash, + filter, + id, + handler, + includeDefaults = false, + nRelays = 5, + liveLocationToken = liveLocationToken + ) } } diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index f591b2b8..980b95ef 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -92,7 +92,7 @@ object RelayDirectory { val c = com.bitchat.android.geohash.Geohash.decodeToCenter(geohash) c } catch (e: Exception) { - Log.e(TAG, "Failed to decode geohash '$geohash': ${e.message}") + Log.e(TAG, "Failed to decode geohash") return emptyList() } @@ -300,4 +300,3 @@ object RelayDirectory { } } } - diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 4eb32739..c62a39e5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -354,42 +354,9 @@ class ChatViewModel( /** * Ensure Nostr DM subscription for a geohash conversation key if known - * Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline */ private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) { - try { - val repoField = GeohashViewModel::class.java.getDeclaredField("repo") - repoField.isAccessible = true - val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository - val gh = repo.getConversationGeohash(convKey) - if (!gh.isNullOrEmpty()) { - val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager") - subMgrField.isAccessible = true - val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager - val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication()) - val subId = "geo-dm-$gh" - val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId") - currentDmSubField.isAccessible = true - val currentId = currentDmSubField.get(geohashViewModel) as String? - if (currentId != subId) { - (currentId)?.let { subMgr.unsubscribe(it) } - currentDmSubField.set(geohashViewModel, subId) - subMgr.subscribeGiftWraps( - pubkey = identity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = subId, - handler = { event -> - val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler") - dmHandlerField.isAccessible = true - val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler - dmHandler.onGiftWrap(event, gh, identity) - } - ) - } - } - } catch (e: Exception) { - Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}") - } + geohashViewModel.ensureGeohashDMSubscriptionForConversation(convKey) } // MARK: - Channel Management (delegated) @@ -955,6 +922,11 @@ class ChatViewModel( fun panicClearAllData() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") + try { + com.bitchat.android.geohash.LocationChannelManager + .getInstance(getApplication()) + .disableLocationServices() + } catch (_: Exception) { } // A pending one-shot downgrade confirmation must not survive panic or // become actionable against the fresh post-wipe identity. @@ -1098,8 +1070,14 @@ class ChatViewModel( /** * Begin sampling multiple geohashes for participant activity */ - fun beginGeohashSampling(geohashes: List) { - geohashViewModel.beginGeohashSampling(geohashes) + fun beginGeohashSampling( + liveLocationGeohashes: Collection, + userSelectedGeohashes: Collection, + ) { + geohashViewModel.beginGeohashSampling( + liveLocationGeohashes = liveLocationGeohashes, + userSelectedGeohashes = userSelectedGeohashes + ) } /** diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index b338c864..4f37d47a 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -3,6 +3,7 @@ package com.bitchat.android.ui import android.content.Context import android.content.SharedPreferences import android.util.Log +import com.bitchat.android.geohash.DEFAULT_LIVE_LOCATION_ENABLED import com.google.gson.Gson import kotlin.random.Random @@ -54,7 +55,7 @@ class DataManager(private val context: Context) { fun saveLastGeohashChannel(channelData: String) { prefs.edit().putString("last_geohash_channel", channelData).apply() - Log.d(TAG, "Saved last geohash channel: $channelData") + Log.d(TAG, "Saved last geohash channel") } fun clearLastGeohashChannel() { @@ -65,12 +66,12 @@ class DataManager(private val context: Context) { // MARK: - Location Services State fun saveLocationServicesEnabled(enabled: Boolean) { - prefs.edit().putBoolean("location_services_enabled", enabled).apply() + prefs.edit().putBoolean("location_services_enabled", enabled).commit() Log.d(TAG, "Saved location services enabled state: $enabled") } fun isLocationServicesEnabled(): Boolean { - return prefs.getBoolean("location_services_enabled", true) // Default to enabled + return prefs.getBoolean("location_services_enabled", DEFAULT_LIVE_LOCATION_ENABLED) } // MARK: - Channel Data Management diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 0504aa11..20b44caf 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -8,6 +8,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.viewModelScope +import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy +import com.bitchat.android.geohash.LiveLocationPrivacyGate import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository import com.bitchat.android.nostr.NostrDirectMessageHandler @@ -18,15 +20,18 @@ import com.bitchat.android.nostr.NostrSubscriptionManager import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import java.util.Date import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.isActive import kotlinx.coroutines.Dispatchers import java.security.SecureRandom +import java.util.UUID import kotlin.random.asKotlinRandom class GeohashViewModel( @@ -69,10 +74,17 @@ class GeohashViewModel( // Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded. private var currentGeohashPresenceSubId: String? = null private var currentDmSubId: String? = null + private var currentDmGeohash: String? = null private var geoTimer: Job? = null private var globalPresenceJob: Job? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null private val activeSamplingGeohashes = mutableSetOf() + private var requestedLiveSamplingGeohashes: Set = emptySet() + private var requestedUserSamplingGeohashes: Set = emptySet() + private val liveLocationRevocationListener: () -> Unit = { + activeSamplingGeohashes.removeAll { it !in requestedUserSamplingGeohashes } + requestedLiveSamplingGeohashes = emptySet() + } // Geohash of the currently selected Location channel (null for Mesh/none). private var activeChannelGeohash: String? = null @@ -81,6 +93,10 @@ class GeohashViewModel( val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts val selectedLocationChannel: StateFlow = state.selectedLocationChannel + init { + LiveLocationPrivacyGate.addRevocationListener(liveLocationRevocationListener) + } + fun initialize() { subscriptionManager.connect() // Observe process lifecycle to manage background sampling @@ -124,10 +140,13 @@ class GeohashViewModel( private fun startGlobalPresenceHeartbeat() { globalPresenceJob?.cancel() globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { - // Reactively restart heartbeat whenever available channels change - locationChannelManager?.availableChannels?.collectLatest { channels -> - // Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5 - val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash } + val manager = locationChannelManager ?: return@launch + combine( + manager.availableChannels, + LiveLocationPrivacyGate.enabled + ) { channels, enabled -> + GeohashNostrPrivacyPolicy.livePresenceTargets(channels, enabled) + }.collectLatest { targetGeohashes -> if (targetGeohashes.isNotEmpty()) { // Enter heartbeat loop for this set of channels @@ -145,8 +164,10 @@ class GeohashViewModel( delay(stepDelay) timeSpent += stepDelay - broadcastPresence(geohash) + broadcastLiveLocationPresence(geohash) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG, "Global presence heartbeat error: ${e.message}") } @@ -172,6 +193,7 @@ class GeohashViewModel( currentGeohashMsgSubId = null currentGeohashPresenceSubId = null currentDmSubId = null + currentDmGeohash = null activeChannelGeohash = null geoTimer?.cancel() geoTimer = null @@ -181,22 +203,52 @@ class GeohashViewModel( initialize() } - private suspend fun broadcastPresence(geohash: String) { + private suspend fun broadcastLiveLocationPresence(geohash: String) { + val manager = locationChannelManager ?: return + val token = LiveLocationPrivacyGate.captureToken() ?: return + val isCurrentLiveTarget = GeohashNostrPrivacyPolicy.livePresenceTargets( + manager.availableChannels.value, + liveLocationEnabled = true + ).contains(geohash) + if (!isCurrentLiveTarget || !LiveLocationPrivacyGate.accepts(token)) return + try { - val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) - val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity) - val relayManager = NostrRelayManager.getInstance(getApplication()) - // Presence is lightweight, send to geohash relays - relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5) - Log.v(TAG, "💓 Sent presence heartbeat for $geohash") + var identity: com.bitchat.android.nostr.NostrIdentity? = null + LiveLocationPrivacyGate.runIfAllowed(token) { + identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) + } + val preparedIdentity = identity ?: return + if (!LiveLocationPrivacyGate.accepts(token)) return + val event = NostrProtocol.createGeohashPresenceEvent(geohash, preparedIdentity) + LiveLocationPrivacyGate.runIfAllowed(token) { + val relayManager = NostrRelayManager.getInstance(getApplication()) + relayManager.sendEventToGeohash( + event, + geohash, + includeDefaults = false, + nRelays = 5, + liveLocationToken = token + ) + } } catch (e: Exception) { - Log.w(TAG, "Failed to send presence for $geohash: ${e.message}") + Log.w(TAG, "Failed to send live-location presence") } } fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { viewModelScope.launch { try { + val canUseChannel = locationChannelManager + ?.canUseSelectedLocationChannel(channel) == true + if (!canUseChannel) { + Log.w(TAG, "Blocked message to a stale live-location channel") + return@launch + } + val isLiveDerived = locationChannelManager + ?.isSelectedChannelLiveDerived(channel) == true + val liveLocationToken = locationChannelManager + ?.liveLocationTokenForSelectedChannel(channel) + if (isLiveDerived && liveLocationToken == null) return@launch val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}" val pow = PoWPreferenceManager.getCurrentSettings() val localMsg = com.bitchat.android.model.BitchatMessage( @@ -216,10 +268,17 @@ class GeohashViewModel( } try { val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) - val teleported = state.isTeleported.value + val teleported = locationChannelManager?.teleported?.value + ?: state.isTeleported.value val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) val relayManager = NostrRelayManager.getInstance(getApplication()) - relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) + relayManager.sendEventToGeohash( + event, + channel.geohash, + includeDefaults = false, + nRelays = 5, + liveLocationToken = liveLocationToken + ) } finally { // Ensure we stop the per-message mining animation regardless of success/failure if (startedMining) { @@ -232,15 +291,22 @@ class GeohashViewModel( } } - fun beginGeohashSampling(geohashes: List) { - if (geohashes.isEmpty()) { - endGeohashSampling() - return - } - - // Diffing logic to avoid redundant REQ and leaks + fun beginGeohashSampling( + liveLocationGeohashes: Collection, + userSelectedGeohashes: Collection, + ) { + requestedLiveSamplingGeohashes = liveLocationGeohashes.toSet() + requestedUserSamplingGeohashes = userSelectedGeohashes.toSet() + reconcileSamplingSubscriptions() + } + + private fun reconcileSamplingSubscriptions() { val currentSet = activeSamplingGeohashes.toSet() - val newSet = geohashes.toSet() + val newSet = GeohashNostrPrivacyPolicy.samplingTargets( + liveLocationGeohashes = requestedLiveSamplingGeohashes, + userSelectedGeohashes = requestedUserSamplingGeohashes, + liveLocationEnabled = LiveLocationPrivacyGate.isEnabled + ) val toRemove = currentSet - newSet val toAdd = newSet - currentSet @@ -265,6 +331,8 @@ class GeohashViewModel( } fun endGeohashSampling() { + requestedLiveSamplingGeohashes = emptySet() + requestedUserSamplingGeohashes = emptySet() if (activeSamplingGeohashes.isEmpty()) return Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)") @@ -287,7 +355,7 @@ class GeohashViewModel( GeohashConversationRegistry.set(convKey, gh) } onStartPrivateChat(convKey) - Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})") + Log.d(TAG, "🗨️ Started geohash DM") } fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) { @@ -340,6 +408,16 @@ class GeohashViewModel( locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") } } + fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) { + val geohash = repo.getConversationGeohash(conversationKey) ?: return + if (currentDmGeohash == geohash && currentDmSubId != null) return + + currentDmSubId?.let(subscriptionManager::unsubscribe) + currentDmSubId = null + currentDmGeohash = null + subscribeChannelDM(geohash) + } + fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) @@ -353,6 +431,7 @@ class GeohashViewModel( currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null } currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null } currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null } + currentDmGeohash = null when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { @@ -364,7 +443,12 @@ class GeohashViewModel( repo.refreshGeohashPeople() } is com.bitchat.android.geohash.ChannelID.Location -> { - Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}") + if (locationChannelManager?.canUseSelectedLocationChannel(channel.channel) != true) { + Log.w(TAG, "Ignoring a stale live-location channel selection") + switchLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) + return + } + Log.d(TAG, "📍 Switching to geohash channel") activeChannelGeohash = channel.channel.geohash repo.setCurrentGeohash(channel.channel.geohash) repo.refreshGeohashPeople() @@ -375,19 +459,22 @@ class GeohashViewModel( try { val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) // We don't update participant here anymore; presence loop handles it via Kind 20001 - val teleported = state.isTeleported.value + val teleported = locationChannelManager?.teleported?.value + ?: state.isTeleported.value if (teleported) repo.markTeleported(identity.publicKeyHex) } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } startGeoParticipantsTimer() + val liveLocationToken = locationChannelManager + ?.liveLocationTokenForSelectedChannel(channel.channel) // Chat message stream (kind 20000) is low-volume; keep it alive even when // backgrounded so geohash messages still arrive. - subscribeChannelMessages(channel.channel.geohash) + subscribeChannelMessages(channel.channel.geohash, liveLocationToken) // Presence heartbeat firehose (kind 20001) is the high-volume data hog; only // run it in the foreground. It is restored in onStart() and torn down in onStop(). if (isAppInForeground()) { - subscribeChannelPresence(channel.channel.geohash) + subscribeChannelPresence(channel.channel.geohash, liveLocationToken) } // Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is // kept alive in the background so geohash DMs still arrive. @@ -405,14 +492,18 @@ class GeohashViewModel( * Subscribe to the chat message stream (kind 20000) for a geohash channel. * Low-volume; kept alive in the background so messages keep arriving. */ - private fun subscribeChannelMessages(geohash: String) { + private fun subscribeChannelMessages( + geohash: String, + liveLocationToken: Long? + ) { val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId subscriptionManager.subscribeGeohashMessages( geohash = geohash, sinceMs = System.currentTimeMillis() - 3600000L, limit = 200, id = subId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + liveLocationToken = liveLocationToken ) } @@ -421,14 +512,18 @@ class GeohashViewModel( * High-volume; only used to refresh the participant list, so it is torn down in * onStop() and restored in onStart() to cut background mobile data. */ - private fun subscribeChannelPresence(geohash: String) { + private fun subscribeChannelPresence( + geohash: String, + liveLocationToken: Long? + ) { val subId = "geohash-presence-$geohash"; currentGeohashPresenceSubId = subId subscriptionManager.subscribeGeohashPresence( geohash = geohash, sinceMs = System.currentTimeMillis() - 3600000L, limit = 200, id = subId, - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + liveLocationToken = liveLocationToken ) } @@ -437,18 +532,18 @@ class GeohashViewModel( * Lightweight (filtered to our pubkey); kept alive in the background. */ private fun subscribeChannelDM(geohash: String) { - viewModelScope.launch { - val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) - val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId - subscriptionManager.subscribeGiftWraps( - pubkey = dmIdentity.publicKeyHex, - sinceMs = System.currentTimeMillis() - 172800000L, - id = dmSubId, - handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) } - ) - // Also register alias in global registry for routing convenience - GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) - } + val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) + val dmSubId = "geo-dm-${UUID.randomUUID()}" + currentDmSubId = dmSubId + currentDmGeohash = geohash + subscriptionManager.subscribeGiftWraps( + pubkey = dmIdentity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172800000L, + id = dmSubId, + handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) } + ) + // Also register alias in global registry for routing convenience + GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) } private fun startGeoParticipantsTimer() { @@ -465,13 +560,25 @@ class GeohashViewModel( kotlin.runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } + LiveLocationPrivacyGate.removeRevocationListener(liveLocationRevocationListener) } override fun onStart(owner: LifecycleOwner) { Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming") // Restore the presence heartbeat firehose for the selected geohash channel. // (The chat message stream is kept alive in the background, so it is not restored here.) - activeChannelGeohash?.let { subscribeChannelPresence(it) } + val selected = locationChannelManager?.selectedChannel?.value + val selectedLocation = selected as? com.bitchat.android.geohash.ChannelID.Location + if (selectedLocation != null && + selectedLocation.channel.geohash == activeChannelGeohash && + locationChannelManager?.canUseSelectedLocationChannel(selectedLocation.channel) == true + ) { + subscribeChannelPresence( + selectedLocation.channel.geohash, + locationChannelManager + ?.liveLocationTokenForSelectedChannel(selectedLocation.channel) + ) + } // Resume geohash sampling subscriptions activeSamplingGeohashes.forEach { performSubscribeSampling(it) } // Resume the participant-refresh polling timer if a geohash is selected @@ -502,13 +609,36 @@ class GeohashViewModel( private fun performSubscribeSampling(geohash: String) { // Sampling only needs participant counts, never message bodies, so it subscribes to // presence heartbeats only (kind 20001) to keep the payload small. - subscriptionManager.subscribeGeohashPresence( - geohash = geohash, - sinceMs = System.currentTimeMillis() - 86400000L, - limit = 200, - id = "sampling-$geohash", - handler = { event -> geohashMessageHandler.onEvent(event, geohash) } - ) + val subscribe = { + subscriptionManager.subscribeGeohashPresence( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 86400000L, + limit = 200, + id = "sampling-$geohash", + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + } + + if (geohash in requestedUserSamplingGeohashes) { + subscribe() + } else if (geohash in requestedLiveSamplingGeohashes) { + val isCurrentLiveTarget = locationChannelManager + ?.availableChannels + ?.value + ?.any { it.geohash == geohash } == true + if (!isCurrentLiveTarget) return + val token = LiveLocationPrivacyGate.captureToken() ?: return + LiveLocationPrivacyGate.runIfAllowed(token) { + subscriptionManager.subscribeGeohashPresence( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 86400000L, + limit = 200, + id = "sampling-$geohash", + handler = { event -> geohashMessageHandler.onEvent(event, geohash) }, + liveLocationToken = token + ) + } + } } private fun isAppInForeground(): Boolean { diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 0732d912..2480ead0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -39,6 +39,7 @@ import com.bitchat.android.nostr.geohashesForSampling import com.bitchat.android.ui.theme.BASE_FONT_SIZE import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LifecycleResumeEffect import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar @@ -236,10 +237,9 @@ fun LocationChannelsSheet( } }, onClick = { - // Selecting a suggested nearby channel is not a teleport - locationManager.setTeleported(false) - locationManager.select(ChannelID.Location(channel)) - onDismiss() + if (locationManager.selectNearby(channel)) { + onDismiss() + } } ) } @@ -306,14 +306,13 @@ fun LocationChannelsSheet( } }, onClick = { - // For bookmarked selection, mark teleported based on regional membership val inRegional = availableChannels.any { it.geohash == gh } - if (!inRegional && availableChannels.isNotEmpty()) { - locationManager.setTeleported(true) - } else { - locationManager.setTeleported(false) - } - locationManager.select(ChannelID.Location(channel)) + locationManager.selectManual( + channel = channel, + teleported = !appLocationEnabled || + availableChannels.isEmpty() || + !inRegional + ) onDismiss() } ) @@ -420,9 +419,7 @@ fun LocationChannelsSheet( if (isValid) { val level = levelForLength(normalized.length) val channel = GeohashChannel(level = level, geohash = normalized) - // Mark this selection as a manual teleport - locationManager.setTeleported(true) - locationManager.select(ChannelID.Location(channel)) + locationManager.selectManual(channel) onDismiss() } else { customError = context.getString(R.string.invalid_geohash) @@ -480,19 +477,19 @@ fun LocationChannelsSheet( ) { Button( onClick = { - if (locationServicesEnabled) { + if (appLocationEnabled) { locationManager.disableLocationServices() } else { locationManager.enableLocationServices() } }, colors = ButtonDefaults.buttonColors( - containerColor = if (locationServicesEnabled) { + containerColor = if (appLocationEnabled) { Color.Red.copy(alpha = 0.08f) } else { standardGreen.copy(alpha = 0.12f) }, - contentColor = if (locationServicesEnabled) { + contentColor = if (appLocationEnabled) { Color(0xFFBF1A1A) } else { standardGreen @@ -501,7 +498,7 @@ fun LocationChannelsSheet( modifier = Modifier.fillMaxWidth() ) { Text( - text = if (locationServicesEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services), + text = if (appLocationEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services), fontSize = 12.sp, fontFamily = FontFamily.Monospace ) @@ -525,26 +522,40 @@ fun LocationChannelsSheet( } // Lifecycle management: when presented, manage location updates - DisposableEffect(isPresented, permissionState, locationServicesEnabled) { - if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - locationManager.refreshChannels() - locationManager.beginLiveRefresh() + LifecycleResumeEffect(isPresented, appLocationEnabled, systemLocationEnabled) { + if (isPresented) { + val currentPermission = locationManager.syncPermissionState() + if (appLocationEnabled && + systemLocationEnabled && + currentPermission == LocationChannelManager.PermissionState.AUTHORIZED + ) { + locationManager.beginLiveRefresh() + } } - onDispose { + onPauseOrDispose { locationManager.endLiveRefresh() } } // Sampling management: update sampling when channels/bookmarks change - LaunchedEffect(isPresented, availableChannels, bookmarks, notesRevealed) { + LaunchedEffect( + isPresented, + availableChannels, + bookmarks, + appLocationEnabled, + notesRevealed + ) { if (isPresented) { - val geohashes = geohashesForSampling( + val liveLocationGeohashes = geohashesForSampling( availableChannels = availableChannels, - bookmarks = bookmarks, + bookmarks = emptyList(), notesRevealed = notesRevealed, ) - viewModel.beginGeohashSampling(geohashes) + viewModel.beginGeohashSampling( + liveLocationGeohashes = liveLocationGeohashes, + userSelectedGeohashes = bookmarks + ) } else { viewModel.endGeohashSampling() } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt index 275dbaf8..2caff0fe 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt @@ -3,7 +3,6 @@ package com.bitchat.android.ui import android.content.Context import android.content.Intent import androidx.core.net.toUri -import com.bitchat.android.geohash.ChannelID import com.bitchat.android.geohash.GeohashChannel import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager @@ -48,6 +47,5 @@ internal fun channelForGeohash(geohash: String): GeohashChannel { internal fun navigateToGeohash(context: Context, geohash: String): Boolean = runCatching { val locationManager = LocationChannelManager.getInstance(context) - locationManager.setTeleported(true) - locationManager.select(ChannelID.Location(channelForGeohash(geohash))) + locationManager.selectManual(channelForGeohash(geohash)) }.isSuccess diff --git a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt index 69e84ff5..f15ada44 100644 --- a/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/NotificationManager.kt @@ -138,7 +138,7 @@ class NotificationManager( */ fun setCurrentGeohash(geohash: String?) { currentGeohash = geohash - Log.d(TAG, "Current geohash changed: $geohash") + Log.d(TAG, "Current geohash changed") } /** @@ -446,11 +446,11 @@ class NotificationManager( val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash) if (!shouldNotify) { - Log.d(TAG, "Skipping geohash notification - app in foreground and viewing geohash $geohash") + Log.d(TAG, "Skipping geohash notification while viewing the channel") return } - Log.d(TAG, "Showing geohash notification for $geohash from $senderNickname (mention: $isMention, first: $isFirstMessage)") + Log.d(TAG, "Showing geohash notification (mention: $isMention, first: $isFirstMessage)") val notification = GeohashNotification( geohash = geohash, @@ -652,7 +652,7 @@ class NotificationManager( showGeohashSummaryNotification() } - Log.d(TAG, "Cleared notifications for geohash: $geohash") + Log.d(TAG, "Cleared notifications for geohash") } /** diff --git a/app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt b/app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt new file mode 100644 index 00000000..3c6217af --- /dev/null +++ b/app/src/test/java/com/bitchat/android/geohash/GeohashNostrPrivacyPolicyTest.kt @@ -0,0 +1,46 @@ +package com.bitchat.android.geohash + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GeohashNostrPrivacyPolicyTest { + @Test + fun `disabled live location retains only user-selected sampling targets`() { + val targets = GeohashNostrPrivacyPolicy.samplingTargets( + liveLocationGeohashes = listOf("live-city", "shared"), + userSelectedGeohashes = listOf("bookmark", "shared"), + liveLocationEnabled = false + ) + + assertEquals(setOf("bookmark", "shared"), targets) + } + + @Test + fun `enabled live location combines and deduplicates sampling targets`() { + val targets = GeohashNostrPrivacyPolicy.samplingTargets( + liveLocationGeohashes = listOf("live-city", "shared"), + userSelectedGeohashes = listOf("bookmark", "shared"), + liveLocationEnabled = true + ) + + assertEquals(setOf("live-city", "bookmark", "shared"), targets) + } + + @Test + fun `presence excludes live channels when disabled`() { + val channels = listOf( + GeohashChannel(GeohashChannelLevel.REGION, "region"), + GeohashChannel(GeohashChannelLevel.CITY, "city"), + GeohashChannel(GeohashChannelLevel.NEIGHBORHOOD, "neighborhood") + ) + + assertEquals( + emptySet(), + GeohashNostrPrivacyPolicy.livePresenceTargets(channels, false) + ) + assertEquals( + setOf("region", "city"), + GeohashNostrPrivacyPolicy.livePresenceTargets(channels, true) + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt b/app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt new file mode 100644 index 00000000..93cef8ca --- /dev/null +++ b/app/src/test/java/com/bitchat/android/geohash/LiveLocationAccessPolicyTest.kt @@ -0,0 +1,48 @@ +package com.bitchat.android.geohash + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LiveLocationAccessPolicyTest { + @Test + fun `live location is disabled by default`() { + val policy = LiveLocationAccessPolicy() + + assertFalse(policy.isEnabled) + assertNull(policy.captureToken()) + } + + @Test + fun `disable and re-enable never revives old work`() { + val policy = LiveLocationAccessPolicy(initialEnabled = true) + val oldToken = requireNotNull(policy.captureToken()) + + policy.update(false) + assertFalse(policy.accepts(oldToken)) + + policy.update(true) + val newToken = requireNotNull(policy.captureToken()) + + assertFalse(policy.accepts(oldToken)) + assertTrue(policy.accepts(newToken)) + } + + @Test + fun `invalidation prevents a queued action from running`() { + val policy = LiveLocationAccessPolicy(initialEnabled = true) + val token = requireNotNull(policy.captureToken()) + var ran = false + + policy.invalidate() + val accepted = policy.runIfAllowed(token) { ran = true } + + assertFalse(accepted) + assertFalse(ran) + assertNull(policy.captureToken()) + + policy.resumeAccess() + assertTrue(policy.captureToken() != null) + } +}