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