diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index 56cd3509..2ae6bab4 100644
--- a/app/src/main/java/com/bitchat/android/MainActivity.kt
+++ b/app/src/main/java/com/bitchat/android/MainActivity.kt
@@ -21,6 +21,7 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
+import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.onboarding.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager
@@ -743,6 +744,9 @@ class MainActivity : OrientationAwareActivity() {
override fun onResume() {
super.onResume()
+ // Revoke stale live-location work before any resumed UI can use cached channels.
+ LocationChannelManager.getInstance(applicationContext).syncPermissionState()
+
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
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 5731a6f6..a6c72852 100644
--- a/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt
+++ b/app/src/main/java/com/bitchat/android/geohash/AndroidGeocoderProvider.kt
@@ -30,7 +30,16 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
maxResults,
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: MutableList
) {
- if (cont.isActive) cont.resume(addresses)
+ if (cont.isActive) {
+ val result = if (liveLocationToken == null ||
+ LiveLocationPrivacyGate.accepts(liveLocationToken)
+ ) {
+ addresses
+ } else {
+ emptyList()
+ }
+ cont.resume(result)
+ }
}
override fun onError(errorMessage: String?) {
@@ -59,20 +68,25 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
} else {
@Suppress("DEPRECATION")
try {
- var addresses: List = emptyList()
- val request = {
- addresses = geocoder.getFromLocation(
- latitude,
- longitude,
- maxResults
- ) ?: emptyList()
- }
- if (liveLocationToken == null) {
- request()
+ if (liveLocationToken != null &&
+ !LiveLocationPrivacyGate.accepts(liveLocationToken)
+ ) return emptyList()
+
+ // This legacy API blocks and cannot be cancelled. Never hold the privacy
+ // gate's read lock across the call: revocation must remain immediate.
+ val addresses = geocoder.getFromLocation(
+ latitude,
+ longitude,
+ maxResults
+ ) ?: emptyList()
+
+ if (liveLocationToken == null ||
+ LiveLocationPrivacyGate.accepts(liveLocationToken)
+ ) {
+ addresses
} else {
- LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, request)
+ emptyList()
}
- addresses
} catch (e: Exception) {
Log.e(TAG, "Geocode failed")
emptyList()
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 1a436f2d..a6927d71 100644
--- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import java.util.UUID
/**
* Manages location notes (kind=1 text notes with geohash tags)
@@ -378,7 +379,7 @@ class LocationNotesManager private constructor() {
since = null,
limit = 200
)
- val subId = "location-notes-$gh"
+ val subId = "location-notes-${UUID.randomUUID()}"
try {
var id: String? = null
LiveLocationPrivacyGate.runIfAllowed(token) {
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacy.kt b/app/src/main/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacy.kt
new file mode 100644
index 00000000..77f27387
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacy.kt
@@ -0,0 +1,13 @@
+package com.bitchat.android.nostr
+
+internal object NostrLiveSubscriptionPrivacy {
+ fun closeTargets(
+ liveSubscriptionIds: Set,
+ subscriptionsByRelay: Map>,
+ ): Map> = buildMap {
+ subscriptionsByRelay.forEach { (relayUrl, relaySubscriptionIds) ->
+ val matchingIds = relaySubscriptionIds.intersect(liveSubscriptionIds)
+ if (matchingIds.isNotEmpty()) put(relayUrl, matchingIds)
+ }
+ }
+}
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 c707c88c..5e82612f 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
@@ -10,6 +10,7 @@ import com.google.gson.JsonArray
import com.google.gson.JsonParser
import kotlinx.coroutines.*
import okhttp3.*
+import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.math.min
@@ -254,6 +255,33 @@ class NostrRelayManager private constructor() {
LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, action)
}
+ /**
+ * Privacy teardown is allowed to bypass an already-revoked token solely to stop
+ * server-side delivery. Live subscription IDs are opaque, so CLOSE carries no
+ * geohash. If a CLOSE cannot be queued, fail closed by dropping that socket.
+ */
+ private fun closeSubscriptionsOnConnectedRelays(subscriptionIds: Set) {
+ if (subscriptionIds.isEmpty()) return
+
+ val closeTargets = NostrLiveSubscriptionPrivacy.closeTargets(
+ liveSubscriptionIds = subscriptionIds,
+ subscriptionsByRelay = subscriptions,
+ )
+ closeTargets.forEach { (relayUrl, relaySubscriptionIds) ->
+ val webSocket = connections[relayUrl] ?: return@forEach
+ relaySubscriptionIds.forEach { subscriptionId ->
+ val request = NostrRequest.Close(subscriptionId)
+ val message = gson.toJson(request, NostrRequest::class.java)
+ val closeQueued = runCatching { webSocket.send(message) }
+ .getOrDefault(false)
+ if (!closeQueued) {
+ connections.remove(relayUrl, webSocket)
+ webSocket.cancel()
+ }
+ }
+ }
+ }
+
private fun revokeLiveLocationAccess() {
liveLocationConnectionJobs.forEach(Job::cancel)
liveLocationConnectionJobs.clear()
@@ -261,6 +289,7 @@ class NostrRelayManager private constructor() {
val liveSubscriptionIds = activeSubscriptions.values
.filter { it.liveLocationToken != null }
.mapTo(mutableSetOf()) { it.id }
+ closeSubscriptionsOnConnectedRelays(liveSubscriptionIds)
liveSubscriptionIds.forEach { id ->
activeSubscriptions.remove(id)
messageHandlers.remove(id)
@@ -452,13 +481,13 @@ class NostrRelayManager private constructor() {
var success = false
runNetworkAction(subscriptionInfo.liveLocationToken) {
success = webSocket.send(message)
+ if (success) {
+ val currentSubs = subscriptions[relayUrl] ?: emptySet()
+ subscriptions[relayUrl] =
+ currentSubs + subscriptionInfo.id
+ }
}
- if (success) {
- // Track subscription for this relay
- val currentSubs = subscriptions[relayUrl] ?: emptySet()
- subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
-
- } else {
+ if (!success) {
Log.w(TAG, "Failed to send subscription: WebSocket send failed")
}
} catch (e: Exception) {
@@ -485,11 +514,20 @@ class NostrRelayManager private constructor() {
return
}
+ if (subscriptionInfo.liveLocationToken != null &&
+ !isNetworkActionAllowed(subscriptionInfo.liveLocationToken)
+ ) {
+ closeSubscriptionsOnConnectedRelays(setOf(id))
+ subscriptions.replaceAll { _, ids -> ids - id }
+ return
+ }
+
val request = NostrRequest.Close(id)
val message = gson.toJson(request, NostrRequest::class.java)
scope.launch {
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
+ closeSubscriptionsOnConnectedRelays(setOf(id))
subscriptions.replaceAll { _, ids -> ids - id }
return@launch
}
@@ -926,7 +964,7 @@ class NostrRelayManager private constructor() {
}
private fun generateSubscriptionId(): String {
- return "sub-${System.currentTimeMillis()}-${(Math.random() * 1000).toInt()}"
+ return "sub-${UUID.randomUUID()}"
}
/**
@@ -952,13 +990,13 @@ class NostrRelayManager private constructor() {
var success = false
runNetworkAction(subscriptionInfo.liveLocationToken) {
success = webSocket.send(message)
+ if (success) {
+ val currentSubs = subscriptions[relayUrl] ?: emptySet()
+ subscriptions[relayUrl] =
+ currentSubs + subscriptionInfo.id
+ }
}
- if (success) {
- // Track subscription for this relay
- val currentSubs = subscriptions[relayUrl] ?: emptySet()
- subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
-
- } else {
+ if (!success) {
Log.w(TAG, "Failed to restore subscription: WebSocket send failed")
}
} catch (e: Exception) {
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 20b44caf..9d949e54 100644
--- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
+++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
@@ -79,10 +79,17 @@ class GeohashViewModel(
private var globalPresenceJob: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
private val activeSamplingGeohashes = mutableSetOf()
+ private val samplingSubscriptionIds = mutableMapOf()
+ private val liveSamplingSubscriptionGeohashes = mutableSetOf()
private var requestedLiveSamplingGeohashes: Set = emptySet()
private var requestedUserSamplingGeohashes: Set = emptySet()
private val liveLocationRevocationListener: () -> Unit = {
- activeSamplingGeohashes.removeAll { it !in requestedUserSamplingGeohashes }
+ val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet()
+ revokedLiveGeohashes.forEach { geohash ->
+ samplingSubscriptionIds.remove(geohash)
+ activeSamplingGeohashes.remove(geohash)
+ }
+ liveSamplingSubscriptionGeohashes.clear()
requestedLiveSamplingGeohashes = emptySet()
}
@@ -310,17 +317,27 @@ class GeohashViewModel(
val toRemove = currentSet - newSet
val toAdd = newSet - currentSet
+ val toPromoteToUserSelection = currentSet
+ .intersect(requestedUserSamplingGeohashes)
+ .intersect(liveSamplingSubscriptionGeohashes)
- if (toAdd.isEmpty() && toRemove.isEmpty()) return
+ if (toAdd.isEmpty() && toRemove.isEmpty() && toPromoteToUserSelection.isEmpty()) return
Log.d(TAG, "🌍 Updating sampling: +${toAdd.size} new, -${toRemove.size} removed")
// Remove old subscriptions
toRemove.forEach { geohash ->
- subscriptionManager.unsubscribe("sampling-$geohash")
+ unsubscribeSampling(geohash)
activeSamplingGeohashes.remove(geohash)
}
+ // A bookmark must remain functional after live access is revoked. Replace a
+ // live-tagged subscription with an untagged manual subscription immediately.
+ toPromoteToUserSelection.forEach { geohash ->
+ unsubscribeSampling(geohash)
+ if (isAppInForeground()) performSubscribeSampling(geohash)
+ }
+
// Add new subscriptions
activeSamplingGeohashes.addAll(toAdd)
if (isAppInForeground()) {
@@ -337,7 +354,7 @@ class GeohashViewModel(
Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)")
activeSamplingGeohashes.toList().forEach { geohash ->
- subscriptionManager.unsubscribe("sampling-$geohash")
+ unsubscribeSampling(geohash)
}
activeSamplingGeohashes.clear()
}
@@ -496,7 +513,7 @@ class GeohashViewModel(
geohash: String,
liveLocationToken: Long?
) {
- val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId
+ val subId = "geohash-${UUID.randomUUID()}"; currentGeohashMsgSubId = subId
subscriptionManager.subscribeGeohashMessages(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
@@ -516,7 +533,7 @@ class GeohashViewModel(
geohash: String,
liveLocationToken: Long?
) {
- val subId = "geohash-presence-$geohash"; currentGeohashPresenceSubId = subId
+ val subId = "geohash-presence-${UUID.randomUUID()}"; currentGeohashPresenceSubId = subId
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
@@ -565,6 +582,10 @@ class GeohashViewModel(
override fun onStart(owner: LifecycleOwner) {
Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming")
+ // Android permission may have changed while backgrounded. Invalidate the
+ // process-wide token before restoring any subscription or heartbeat.
+ locationChannelManager?.syncPermissionState()
+
// Restore the presence heartbeat firehose for the selected geohash channel.
// (The chat message stream is kept alive in the background, so it is not restored here.)
val selected = locationChannelManager?.selectedChannel?.value
@@ -597,7 +618,7 @@ class GeohashViewModel(
// The chat message stream (kind 20000) is intentionally left active so messages still arrive.
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
// Drop geohash sampling subscriptions
- activeSamplingGeohashes.forEach { subscriptionManager.unsubscribe("sampling-$it") }
+ activeSamplingGeohashes.forEach(::unsubscribeSampling)
// Stop broadcasting presence heartbeats
globalPresenceJob?.cancel(); globalPresenceJob = null
// Stop participant-refresh polling
@@ -607,14 +628,18 @@ class GeohashViewModel(
}
private fun performSubscribeSampling(geohash: String) {
+ val subscriptionId = samplingSubscriptionIds.getOrPut(geohash) {
+ "sampling-${UUID.randomUUID()}"
+ }
// Sampling only needs participant counts, never message bodies, so it subscribes to
// presence heartbeats only (kind 20001) to keep the payload small.
val subscribe = {
+ liveSamplingSubscriptionGeohashes.remove(geohash)
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
- id = "sampling-$geohash",
+ id = subscriptionId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
}
@@ -629,11 +654,12 @@ class GeohashViewModel(
if (!isCurrentLiveTarget) return
val token = LiveLocationPrivacyGate.captureToken() ?: return
LiveLocationPrivacyGate.runIfAllowed(token) {
+ liveSamplingSubscriptionGeohashes.add(geohash)
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
- id = "sampling-$geohash",
+ id = subscriptionId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = token
)
@@ -641,6 +667,11 @@ class GeohashViewModel(
}
}
+ private fun unsubscribeSampling(geohash: String) {
+ samplingSubscriptionIds.remove(geohash)?.let(subscriptionManager::unsubscribe)
+ liveSamplingSubscriptionGeohashes.remove(geohash)
+ }
+
private fun isAppInForeground(): Boolean {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
diff --git a/app/src/test/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacyTest.kt b/app/src/test/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacyTest.kt
new file mode 100644
index 00000000..a964a686
--- /dev/null
+++ b/app/src/test/java/com/bitchat/android/nostr/NostrLiveSubscriptionPrivacyTest.kt
@@ -0,0 +1,37 @@
+package com.bitchat.android.nostr
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class NostrLiveSubscriptionPrivacyTest {
+ @Test
+ fun `teardown closes live subscriptions on shared relays`() {
+ val targets = NostrLiveSubscriptionPrivacy.closeTargets(
+ liveSubscriptionIds = setOf("live-a", "live-b"),
+ subscriptionsByRelay = mapOf(
+ "shared-relay" to setOf("dm", "live-a"),
+ "live-relay" to setOf("live-a", "live-b"),
+ "dm-relay" to setOf("dm"),
+ ),
+ )
+
+ assertEquals(
+ mapOf(
+ "shared-relay" to setOf("live-a"),
+ "live-relay" to setOf("live-a", "live-b"),
+ ),
+ targets,
+ )
+ }
+
+ @Test
+ fun `teardown ignores relays without live subscriptions`() {
+ assertEquals(
+ emptyMap>(),
+ NostrLiveSubscriptionPrivacy.closeTargets(
+ liveSubscriptionIds = emptySet(),
+ subscriptionsByRelay = mapOf("default-relay" to setOf("dm")),
+ ),
+ )
+ }
+}