diff --git a/.gitignore b/.gitignore
index 45ed4c3c..0986ac74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ build/
!*/build/intermediates/
local.properties
.gradle/
+.kotlin/
captures/
.externalNativeBuild/
debug_keystore/
@@ -40,6 +41,11 @@ dependency-reduced-pom.xml
# Linters
.lint/
+# Python test tooling
+**/__pycache__/
+*.py[cod]
+release-gate-results/
+
# Other
*.log
.cxx/
diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index e6344efa..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
@@ -817,7 +821,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..a6c72852 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,53 @@ 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) {
+ val result = if (liveLocationToken == null ||
+ LiveLocationPrivacyGate.accepts(liveLocationToken)
+ ) {
+ addresses
+ } else {
+ emptyList()
+ }
+ cont.resume(result)
+ }
+ }
- 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 +68,27 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
} else {
@Suppress("DEPRECATION")
try {
- geocoder.getFromLocation(latitude, longitude, maxResults) ?: emptyList()
+ 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 {
+ emptyList()
+ }
} 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/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt
index 8e1731b1..528a269e 100644
--- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt
+++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt
@@ -69,7 +69,15 @@ data class BitchatMessage(
val encryptedContent: ByteArray? = null,
val isEncrypted: Boolean = false,
val deliveryStatus: DeliveryStatus? = null,
- val powDifficulty: Int? = null
+ val powDifficulty: Int? = null,
+ /**
+ * Full canonical Nostr public key supplied by the local Nostr bridge.
+ *
+ * This is local identity metadata, not part of the Bitchat binary wire format. It lets UI
+ * surfaces color the sender by the same stable key while [senderPeerID] remains available for
+ * mesh IDs and private-chat routing aliases.
+ */
+ val senderNostrPubkey: String? = null
) : Parcelable {
/**
@@ -355,4 +363,3 @@ data class BitchatMessage(
}
}
-
diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt
index 1c4896f3..1fc802d9 100644
--- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt
@@ -96,6 +96,7 @@ class GeohashMessageHandler(
isRelay = false,
originalSender = repo.displayNameForNostrPubkey(pubkey),
senderPeerID = "nostr:${pubkey.take(8)}",
+ senderNostrPubkey = pubkey,
mentions = null,
channel = "#$subscribedGeohash",
powDifficulty = try {
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..a6927d71 100644
--- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt
@@ -2,10 +2,12 @@ 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
import kotlinx.coroutines.flow.asStateFlow
+import java.util.UUID
/**
* Manages location notes (kind=1 text notes with geohash tags)
@@ -89,13 +91,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 +111,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 +126,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 +166,7 @@ class LocationNotesManager private constructor() {
subscribedGeohashes = (neighbors + normalized).toSet()
// Start new subscriptions for all cells
- subscribeAll()
+ subscribeAll(token)
}
/**
@@ -170,16 +182,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 +204,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 +229,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 +257,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 +302,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 +323,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 +347,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 +373,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")
+ val subId = "location-notes-${UUID.randomUUID()}"
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 +408,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 +427,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 +453,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 +501,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 +517,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/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt
index 8abb4d6a..9e3bf785 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt
@@ -3,8 +3,11 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.geohash.ChannelID
+import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy
+import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.mesh.PowerManager
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -44,6 +47,7 @@ object NostrBackgroundRuntime {
@Volatile private var handlers: Handlers? = null
@Volatile private var initialized = false
@Volatile private var activeGeohash: String? = null
+ @Volatile private var activeGeohashLiveToken: Long? = null
@Volatile private var conversationGeohash: String? = null
private lateinit var application: Application
private lateinit var subscriptions: NostrSubscriptionManager
@@ -83,7 +87,9 @@ object NostrBackgroundRuntime {
// Let CLOSE frames be queued before replacing the deterministic IDs.
delay(100)
subscribeAccountDm()
- activeGeohash?.let(::subscribeSelectedGeohash)
+ activeGeohash?.let { geohash ->
+ subscribeSelectedGeohash(geohash, activeGeohashLiveToken)
+ }
}
}
@@ -91,7 +97,11 @@ object NostrBackgroundRuntime {
if (!initialized || geohash == activeGeohash || geohash == conversationGeohash) return
conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") }
conversationGeohash = geohash
- subscribeGeohashDm(geohash, "geo-dm-conversation-$geohash")
+ subscribeGeohashDm(
+ geohash = geohash,
+ subscriptionId = "geo-dm-conversation-$geohash",
+ liveLocationToken = null
+ )
}
private fun subscribeAccountDm() {
@@ -110,42 +120,67 @@ object NostrBackgroundRuntime {
scope.launch {
locationChannels.selectedChannel.collectLatest { channel ->
val next = (channel as? ChannelID.Location)?.channel?.geohash
+ val nextToken = (channel as? ChannelID.Location)?.let {
+ locationChannels.liveLocationTokenForSelectedChannel(it.channel)
+ }
val previous = activeGeohash
- if (previous == next) return@collectLatest
+ if (previous == next && activeGeohashLiveToken == nextToken) {
+ return@collectLatest
+ }
previous?.let {
subscriptions.unsubscribe("geohash-$it")
subscriptions.unsubscribe("geo-dm-$it")
}
activeGeohash = next
+ activeGeohashLiveToken = nextToken
if (conversationGeohash == next) {
subscriptions.unsubscribe("geo-dm-conversation-$next")
conversationGeohash = null
}
- next?.let(::subscribeSelectedGeohash)
+ next?.let { geohash ->
+ val isLiveDerived = (channel as? ChannelID.Location)?.let {
+ locationChannels.isSelectedChannelLiveDerived(it.channel)
+ } == true
+ if (!isLiveDerived || nextToken != null) {
+ subscribeSelectedGeohash(geohash, nextToken)
+ }
+ }
}
}
}
- private fun subscribeSelectedGeohash(geohash: String) {
+ private fun subscribeSelectedGeohash(
+ geohash: String,
+ liveLocationToken: Long?
+ ) {
subscriptions.subscribeGeohashMessages(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3_600_000L,
limit = 200,
id = "geohash-$geohash",
- handler = { event -> dispatch { it.geohashMessage(event, geohash) } }
+ handler = { event -> dispatch { it.geohashMessage(event, geohash) } },
+ liveLocationToken = liveLocationToken
)
- subscribeGeohashDm(geohash, "geo-dm-$geohash")
+ subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken)
}
- private fun subscribeGeohashDm(geohash: String, subscriptionId: String) {
+ private fun subscribeGeohashDm(
+ geohash: String,
+ subscriptionId: String,
+ liveLocationToken: Long?
+ ) {
scope.launch {
+ if (liveLocationToken != null &&
+ !LiveLocationPrivacyGate.accepts(liveLocationToken)
+ ) return@launch
val identity = NostrIdentityBridge.deriveIdentity(geohash, application)
subscriptions.subscribeGiftWraps(
pubkey = identity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172_800_000L,
id = subscriptionId,
- handler = { event -> dispatch { it.geohashDm(event, geohash, identity) } }
+ handler = { event -> dispatch { it.geohashDm(event, geohash, identity) } },
+ liveLocationToken = liveLocationToken
)
GeohashAliasRegistry.put(
"nostr_${identity.publicKeyHex.take(16)}",
@@ -160,11 +195,15 @@ object NostrBackgroundRuntime {
val nostrSchedule = powerManager.profile
.map { it.nostr }
.distinctUntilChanged()
- combine(locationChannels.availableChannels, nostrSchedule) { channels, schedule ->
- val targets = channels
- .filter { it.level.precision <= 5 }
- .map { it.geohash }
- .distinct()
+ combine(
+ locationChannels.availableChannels,
+ LiveLocationPrivacyGate.enabled,
+ nostrSchedule
+ ) { channels, liveLocationEnabled, schedule ->
+ val targets = GeohashNostrPrivacyPolicy.livePresenceTargets(
+ availableChannels = channels,
+ liveLocationEnabled = liveLocationEnabled
+ )
targets to schedule
}.collectLatest { (targets, schedule) ->
if (targets.isEmpty()) return@collectLatest
@@ -182,14 +221,35 @@ object NostrBackgroundRuntime {
// Send every target in one wake window; do not spread the batch over seconds.
targets.forEach { geohash ->
try {
- val identity = NostrIdentityBridge.deriveIdentity(geohash, application)
- val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
- NostrRelayManager.getInstance(application).sendEventToGeohash(
- event,
+ val token = LiveLocationPrivacyGate.captureToken()
+ ?: return@forEach
+ if (geohash !in GeohashNostrPrivacyPolicy.livePresenceTargets(
+ availableChannels = locationChannels.availableChannels.value,
+ liveLocationEnabled = true
+ )
+ ) return@forEach
+
+ var identity: NostrIdentity? = null
+ LiveLocationPrivacyGate.runIfAllowed(token) {
+ identity = NostrIdentityBridge.deriveIdentity(geohash, application)
+ }
+ val preparedIdentity = identity ?: return@forEach
+ if (!LiveLocationPrivacyGate.accepts(token)) return@forEach
+ val event = NostrProtocol.createGeohashPresenceEvent(
geohash,
- includeDefaults = false,
- nRelays = 5
+ preparedIdentity
)
+ LiveLocationPrivacyGate.runIfAllowed(token) {
+ NostrRelayManager.getInstance(application).sendEventToGeohash(
+ event = event,
+ geohash = geohash,
+ includeDefaults = false,
+ nRelays = 5,
+ liveLocationToken = token
+ )
+ }
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Log.w(TAG, "Presence heartbeat failed for $geohash: ${e.message}")
}
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/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
index 9d8ae8af..7a49057d 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
@@ -148,6 +148,7 @@ class NostrDirectMessageHandler(
isPrivate = true,
recipientNickname = state.getNicknameValue(),
senderPeerID = conversationID,
+ senderNostrPubkey = senderPubkey,
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
)
@@ -201,7 +202,8 @@ class NostrDirectMessageHandler(
isRelay = false,
isPrivate = true,
recipientNickname = state.getNicknameValue(),
- senderPeerID = conversationID
+ senderPeerID = conversationID,
+ senderNostrPubkey = senderPubkey
)
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
withContext(Dispatchers.Main) {
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/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 20a1ce18..d361ec49 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
@@ -9,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 java.util.concurrent.atomic.AtomicBoolean
@@ -104,8 +106,9 @@ 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 owner: String = OWNER_LEGACY
+ val originGeohash: String? = null,
+ val owner: String = OWNER_LEGACY,
+ val liveLocationToken: Long? = null
)
// Event deduplication system
@@ -114,8 +117,10 @@ class NostrRelayManager private constructor() {
// Message queue for reliability
private data class QueuedEvent(
val event: NostrEvent,
- val pendingRelayUrls: MutableSet
+ val pendingRelayUrls: MutableSet,
+ val liveLocationToken: Long? = null
)
+
private val messageQueue = mutableListOf()
private val messageQueueLock = Any()
@@ -136,27 +141,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")
}
}
@@ -177,20 +204,29 @@ class NostrRelayManager private constructor() {
handler: (NostrEvent) -> Unit,
includeDefaults: Boolean = false,
nRelays: Int = 5,
- owner: String = OWNER_LEGACY
+ owner: String = OWNER_LEGACY,
+ 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,
- owner = owner
- ).also {
- // update origin geohash for this subscription
- activeSubscriptions[it]?.let { sub ->
- activeSubscriptions[it] = sub.copy(originGeohash = geohash)
+ owner = owner,
+ liveLocationToken = liveLocationToken
+ ).also { subscriptionId ->
+ activeSubscriptions[subscriptionId]?.let { subscription ->
+ activeSubscriptions[subscriptionId] =
+ subscription.copy(originGeohash = geohash)
}
}
}
@@ -198,20 +234,111 @@ class NostrRelayManager private constructor() {
/**
* 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)
+ }
+
+ /**
+ * 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()
+
+ val liveSubscriptionIds = activeSubscriptions.values
+ .filter { it.liveLocationToken != null }
+ .mapTo(mutableSetOf()) { it.id }
+ closeSubscriptionsOnConnectedRelays(liveSubscriptionIds)
+ 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 }) {
@@ -221,15 +348,25 @@ class NostrRelayManager private constructor() {
updateRelaysList()
if (!desiredConnected.get()) return
- scope.launch {
+ val job = scope.launch {
+ if (!desiredConnected.get() ||
+ !isNetworkActionAllowed(liveLocationToken)
+ ) return@launch
relayUrls.forEach { relayUrl ->
launch {
- if (!connections.containsKey(relayUrl)) {
- connectToRelay(relayUrl)
+ if (desiredConnected.get() &&
+ !connections.containsKey(relayUrl) &&
+ isNetworkActionAllowed(liveLocationToken)
+ ) {
+ connectToRelay(relayUrl, liveLocationToken)
}
}
}
}
+ if (liveLocationToken != null) {
+ liveLocationConnectionJobs.add(job)
+ job.invokeOnCompletion { liveLocationConnectionJobs.remove(job) }
+ }
}
init {
@@ -242,8 +379,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
@@ -258,11 +397,14 @@ class NostrRelayManager private constructor() {
fun connect() {
desiredConnected.set(true)
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)
+ }
}
}
}
@@ -277,18 +419,20 @@ class NostrRelayManager private constructor() {
fun disconnect() {
Log.i(TAG, "Disconnecting from all Nostr relays")
desiredConnected.set(false)
-
+
// Stop subscription validation
stopSubscriptionValidation()
- reconnectJobs.values.forEach { it.cancel() }
+ reconnectJobs.values.forEach(Job::cancel)
reconnectJobs.clear()
-
+ liveLocationConnectionJobs.forEach(Job::cancel)
+ liveLocationConnectionJobs.clear()
+
val sockets = connections.values.toList()
connections.clear()
sockets.forEach { webSocket ->
webSocket.close(1000, "Manual disconnect")
}
-
+
// Preserve logical subscriptions for controlled resets, but forget per-socket state.
subscriptions.clear()
relaysList.forEach {
@@ -302,26 +446,39 @@ 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) {
- if (messageQueue.size >= MAX_QUEUED_EVENTS) messageQueue.removeAt(0)
- messageQueue.add(QueuedEvent(event, targetRelays.toMutableSet()))
- }
-
- // Attempt immediate send
- scope.launch {
- targetRelays.forEach { relayUrl ->
- val webSocket = connections[relayUrl]
- if (webSocket != null) {
- if (sendToRelay(event, webSocket, relayUrl)) {
- markQueuedRelayDelivered(event.id, relayUrl)
+
+ val queued = runNetworkAction(liveLocationToken) {
+ synchronized(messageQueueLock) {
+ if (messageQueue.size >= MAX_QUEUED_EVENTS) {
+ messageQueue.removeAt(0)
+ }
+ messageQueue.add(
+ QueuedEvent(
+ event = event,
+ pendingRelayUrls = targetRelays.toMutableSet(),
+ liveLocationToken = liveLocationToken
+ )
+ )
+ }
+ scope.launch {
+ if (!isNetworkActionAllowed(liveLocationToken)) return@launch
+ targetRelays.forEach { relayUrl ->
+ val webSocket = connections[relayUrl]
+ if (webSocket != null) {
+ if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) {
+ markQueuedRelayDelivered(event.id, relayUrl)
+ }
}
}
}
}
+ if (!queued) return
}
/**
@@ -333,22 +490,23 @@ class NostrRelayManager private constructor() {
id: String = generateSubscriptionId(),
handler: (NostrEvent) -> Unit,
targetRelayUrls: List? = null,
- owner: String = OWNER_LEGACY
+ owner: String = OWNER_LEGACY,
+ liveLocationToken: Long? = null
): String {
- // Store subscription info for persistent tracking
val subscriptionInfo = SubscriptionInfo(
id = id,
filter = filter,
handler = handler,
targetRelayUrls = targetRelayUrls?.toSet(),
- owner = owner
+ owner = owner,
+ 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
}
@@ -357,32 +515,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)
- 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")
+ var success = false
+ runNetworkAction(subscriptionInfo.liveLocationToken) {
+ success = webSocket.send(message)
+ if (success) {
+ val currentSubs = subscriptions[relayUrl] ?: emptySet()
+ subscriptions[relayUrl] =
+ currentSubs + subscriptionInfo.id
+ }
+ }
+ if (!success) {
+ 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")
}
}
}
@@ -396,7 +560,14 @@ class NostrRelayManager private constructor() {
messageHandlers.remove(id)
if (subscriptionInfo == null) {
- Log.w(TAG, "Attempted to unsubscribe from unknown subscription: $id")
+ return
+ }
+
+ if (subscriptionInfo.liveLocationToken != null &&
+ !isNetworkActionAllowed(subscriptionInfo.liveLocationToken)
+ ) {
+ closeSubscriptionsOnConnectedRelays(setOf(id))
+ subscriptions.replaceAll { _, ids -> ids - id }
return
}
@@ -404,14 +575,21 @@ class NostrRelayManager private constructor() {
val message = gson.toJson(request, NostrRequest::class.java)
scope.launch {
+ if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
+ closeSubscriptionsOnConnectedRelays(setOf(id))
+ 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")
}
}
}
@@ -431,6 +609,9 @@ class NostrRelayManager private constructor() {
fun retryConnection(relayUrl: String) {
val relay = relaysList.find { it.url == relayUrl } ?: return
desiredConnected.set(true)
+ val liveToken = liveLocationRelayTokens[relayUrl]
+ ?.takeIf { relayUrl !in nonLiveRelayUrls }
+ if (!isNetworkActionAllowed(liveToken)) return
// Reset reconnection attempts
relay.reconnectAttempts = 0
@@ -442,7 +623,7 @@ class NostrRelayManager private constructor() {
// Attempt immediate reconnection
scope.launch {
- connectToRelay(relayUrl)
+ connectToRelay(relayUrl, liveToken)
}
}
@@ -596,7 +777,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) ->
@@ -608,7 +789,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)
}
}
@@ -618,6 +799,7 @@ class NostrRelayManager private constructor() {
}
}
}
+
}
/**
@@ -630,8 +812,14 @@ class NostrRelayManager private constructor() {
// MARK: - Private Methods
- private suspend fun connectToRelay(urlString: String) {
+ private suspend fun connectToRelay(
+ urlString: String,
+ liveLocationToken: Long? = null
+ ) {
if (!desiredConnected.get()) return
+ val connectionToken = liveLocationToken
+ ?.takeIf { urlString !in nonLiveRelayUrls }
+ if (!isNetworkActionAllowed(connectionToken)) return
// Skip if we already have a connection
if (connections.containsKey(urlString)) {
return
@@ -642,27 +830,43 @@ class NostrRelayManager private constructor() {
.url(urlString)
.build()
- val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString))
- val existing = connections.putIfAbsent(urlString, webSocket)
- if (existing != null) {
- webSocket.close(1000, "Duplicate connection")
- } else if (!desiredConnected.get()) {
- connections.remove(urlString, webSocket)
- webSocket.close(1000, "Connection no longer desired")
+ val started = runNetworkAction(connectionToken) {
+ val webSocket = httpClient.newWebSocket(
+ request,
+ RelayWebSocketListener(urlString, connectionToken)
+ )
+ val existing = connections.putIfAbsent(urlString, webSocket)
+ when {
+ existing != null -> webSocket.close(1000, "Duplicate connection")
+ !desiredConnected.get() -> {
+ connections.remove(urlString, webSocket)
+ webSocket.close(1000, "Connection no longer desired")
+ }
+ }
}
+ if (!started) return
} catch (e: Exception) {
- Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}")
- handleConnectionCreationFailure(urlString, e)
+ Log.e(TAG, "Failed to create WebSocket connection")
+ handleConnectionCreationFailure(urlString, e, connectionToken)
}
}
- private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String): Boolean {
+ private fun sendToRelay(
+ event: NostrEvent,
+ webSocket: WebSocket,
+ relayUrl: String,
+ liveLocationToken: Long? = null
+ ): Boolean {
+ if (!isNetworkActionAllowed(liveLocationToken)) return false
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 }
@@ -670,11 +874,11 @@ class NostrRelayManager private constructor() {
updateRelaysList()
true
} else {
- Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed")
+ Log.e(TAG, "Failed to send event: WebSocket send failed")
false
}
} catch (e: Exception) {
- Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}")
+ Log.e(TAG, "Failed to send event")
false
}
}
@@ -695,7 +899,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
}
@@ -709,7 +913,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
@@ -723,12 +930,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 -> {
@@ -739,39 +949,57 @@ 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, webSocket: WebSocket, error: Throwable) {
- // A close/failure from an intentionally closed or replaced socket is stale. In particular,
- // it must not remove a newer socket or schedule a reconnect after manual disconnect.
+ private fun handleDisconnection(
+ relayUrl: String,
+ webSocket: WebSocket,
+ error: Throwable,
+ liveLocationToken: Long? = null
+ ) {
+ // Ignore callbacks from intentionally closed or replaced sockets. They must not remove a
+ // newer socket or schedule a reconnect after a controlled disconnect/privacy revocation.
if (!connections.remove(relayUrl, webSocket)) return
subscriptions.remove(relayUrl)
- handleCurrentDisconnection(relayUrl, error)
+ handleCurrentDisconnection(relayUrl, error, liveLocationToken)
}
- private fun handleConnectionCreationFailure(relayUrl: String, error: Throwable) {
+ private fun handleConnectionCreationFailure(
+ relayUrl: String,
+ error: Throwable,
+ liveLocationToken: Long?
+ ) {
if (!desiredConnected.get()) return
- handleCurrentDisconnection(relayUrl, error)
+ handleCurrentDisconnection(relayUrl, error, liveLocationToken)
}
- private fun handleCurrentDisconnection(relayUrl: String, error: Throwable) {
+ private fun handleCurrentDisconnection(
+ relayUrl: String,
+ error: Throwable,
+ liveLocationToken: Long?
+ ) {
+ val connectionToken = liveLocationToken
+ ?.takeIf { relayUrl !in nonLiveRelayUrls }
+
updateRelayStatus(relayUrl, false, error)
- if (!desiredConnected.get()) return
+ if (!desiredConnected.get() ||
+ !isNetworkActionAllowed(connectionToken)
+ ) return
// Check if this is a DNS error
val errorMessage = error.message?.lowercase() ?: ""
@@ -781,7 +1009,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
}
@@ -792,7 +1020,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
}
@@ -804,15 +1032,21 @@ 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")
reconnectJobs.remove(relayUrl)?.cancel()
val reconnectJob = scope.launch {
delay(backoffInterval)
- if (desiredConnected.get()) connectToRelay(relayUrl)
+ if (desiredConnected.get() &&
+ isNetworkActionAllowed(connectionToken)
+ ) {
+ connectToRelay(relayUrl, connectionToken)
+ }
}
reconnectJobs[relayUrl] = reconnectJob
- reconnectJob.invokeOnCompletion { reconnectJobs.remove(relayUrl, reconnectJob) }
+ reconnectJob.invokeOnCompletion {
+ reconnectJobs.remove(relayUrl, reconnectJob)
+ }
}
private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) {
@@ -843,7 +1077,7 @@ class NostrRelayManager private constructor() {
}
private fun generateSubscriptionId(): String {
- return "sub-${System.currentTimeMillis()}-${(Math.random() * 1000).toInt()}"
+ return "sub-${UUID.randomUUID()}"
}
/**
@@ -852,30 +1086,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)
- 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")
+ var success = false
+ runNetworkAction(subscriptionInfo.liveLocationToken) {
+ success = webSocket.send(message)
+ if (success) {
+ val currentSubs = subscriptions[relayUrl] ?: emptySet()
+ subscriptions[relayUrl] =
+ currentSubs + subscriptionInfo.id
+ }
+ }
+ if (!success) {
+ 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")
}
}
}
@@ -883,15 +1121,21 @@ 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) {
- if (!desiredConnected.get() || connections[relayUrl] !== webSocket) {
+ if (!desiredConnected.get() ||
+ connections[relayUrl] !== webSocket ||
+ !isNetworkActionAllowed(liveLocationToken)
+ ) {
+ connections.remove(relayUrl, webSocket)
webSocket.close(1000, "Stale connection")
return
}
reconnectJobs.remove(relayUrl)?.cancel()
- Log.i(TAG, "Connected to Nostr relay: $relayUrl")
updateRelayStatus(relayUrl, true)
// Restore all active subscriptions for this relay
@@ -900,11 +1144,14 @@ class NostrRelayManager private constructor() {
// Process only events still pending for this relay, outside the queue lock.
val queuedForRelay = synchronized(messageQueueLock) {
messageQueue
- .filter { relayUrl in it.pendingRelayUrls }
- .map { it.event }
+ .filter {
+ relayUrl in it.pendingRelayUrls &&
+ isNetworkActionAllowed(it.liveLocationToken)
+ }
+ .map { it.event to it.liveLocationToken }
}
- queuedForRelay.forEach { event ->
- if (sendToRelay(event, webSocket, relayUrl)) {
+ queuedForRelay.forEach { (event, token) ->
+ if (sendToRelay(event, webSocket, relayUrl, token)) {
markQueuedRelayDelivered(event.id, relayUrl)
}
}
@@ -920,14 +1167,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, webSocket, error)
+ handleDisconnection(relayUrl, webSocket, error, liveLocationToken)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
- Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}")
- handleDisconnection(relayUrl, webSocket, t)
+ Log.e(TAG, "Nostr WebSocket failure")
+ handleDisconnection(relayUrl, webSocket, 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 601e885c..e378e31e 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
@@ -21,16 +22,41 @@ class NostrSubscriptionManager(
fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } }
fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } }
- fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) {
+ fun subscribeGiftWraps(
+ pubkey: String,
+ sinceMs: Long,
+ id: String,
+ handler: (NostrEvent) -> Unit,
+ liveLocationToken: Long? = null
+ ) {
scope.launch {
+ if (liveLocationToken != null &&
+ !LiveLocationPrivacyGate.accepts(liveLocationToken)
+ ) return@launch
val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs)
- relayManager.subscribe(filter, id, handler, owner = owner)
+ relayManager.subscribe(
+ filter = filter,
+ id = id,
+ handler = handler,
+ owner = owner,
+ liveLocationToken = liveLocationToken
+ )
}
}
/** 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,
@@ -39,14 +65,25 @@ class NostrSubscriptionManager(
handler,
includeDefaults = false,
nRelays = 5,
- owner = owner
+ owner = owner,
+ 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,
@@ -55,13 +92,13 @@ class NostrSubscriptionManager(
handler,
includeDefaults = false,
nRelays = 5,
- owner = owner
+ owner = owner,
+ liveLocationToken = liveLocationToken
)
}
}
fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } }
-
fun unsubscribeAllOwned() {
scope.launch { runCatching { relayManager.unsubscribeOwner(owner) } }
}
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/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
index 8993b3ea..af38ea1d 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
@@ -42,6 +42,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatFontFamily
+import androidx.annotation.DrawableRes
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.foundation.layout.RowScope
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
import com.bitchat.android.net.ArtiTorManager
@@ -80,6 +83,15 @@ private val HeaderTapTarget = 44.dp
/** Corner radius for the header's tappable label+icon clusters. */
private val HeaderClusterShape = RoundedCornerShape(8.dp)
+/**
+ * Edge insets for the bar.
+ *
+ * Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies
+ * some optical inset, while the trailing action's does the same on the other side.
+ */
+internal val HeaderInsetStart = 12.dp
+internal val HeaderInsetEnd = 8.dp
+
/**
* A minimum-48x40 tap target wrapping a small icon.
*
@@ -273,39 +285,151 @@ fun NoiseSessionIcon(
// The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with
// an all-but-transparent RGB - the icons were effectively invisible. They now use the
// palette's secondary text colour.
- val (icon, color, contentDescription) = when (sessionState) {
+ val (iconRes, color, contentDescription) = when (sessionState) {
"uninitialized" -> Triple(
- Icons.Outlined.NoEncryption,
+ R.drawable.ic_spec_lock_open,
colorScheme.onSurfaceVariant,
stringResource(R.string.cd_ready_for_handshake)
)
"handshaking" -> Triple(
- Icons.Outlined.Sync,
+ R.drawable.ic_spec_sync,
colorScheme.onSurfaceVariant,
stringResource(R.string.cd_handshake_in_progress)
)
"established" -> Triple(
- Icons.Filled.Lock,
- palette.accentOrange,
+ R.drawable.ic_spec_lock,
+ colorScheme.primary,
stringResource(R.string.cd_encrypted)
)
else -> { // "failed" or any other state
Triple(
- Icons.Outlined.Warning,
+ R.drawable.ic_spec_warning,
colorScheme.error,
stringResource(R.string.cd_handshake_failed)
)
}
}
-
+
Icon(
- imageVector = icon,
+ painter = painterResource(iconRes),
contentDescription = contentDescription,
modifier = modifier,
tint = color
)
}
+/**
+ * Reachability glyph for a conversation, drawn from the same spec set the main header uses.
+ *
+ * Mirrors the main header's channel button: a globe for anything reached over the internet, the
+ * range mark for the local mesh, and the more specific transport glyph when we know it.
+ */
+@DrawableRes
+internal fun conversationTransportIcon(
+ isReachedOverInternet: Boolean,
+ isWifiAware: Boolean,
+ isDirect: Boolean
+): Int = when {
+ isReachedOverInternet -> R.drawable.ic_spec_globe
+ isWifiAware -> R.drawable.ic_spec_wifi
+ isDirect -> R.drawable.ic_spec_bluetooth
+ else -> R.drawable.ic_spec_routed
+}
+
+/**
+ * The shared chrome for a conversation header — private chats and channels alike.
+ *
+ * Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical
+ * height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it
+ * lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward
+ * that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you
+ * move between the main timeline and a conversation.
+ *
+ * Actions are right-aligned and unweighted, so a long title yields space to them rather than
+ * pushing them off screen.
+ */
+@Composable
+fun ConversationHeader(
+ @DrawableRes leadingIconRes: Int,
+ leadingIconTint: Color,
+ title: String,
+ modifier: Modifier = Modifier,
+ onTitleClick: (() -> Unit)? = null,
+ leadingContentDescription: String? = null,
+ actions: @Composable RowScope.() -> Unit = {}
+) {
+ val colorScheme = MaterialTheme.colorScheme
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(ChatHeaderHeight)
+ .padding(start = HeaderInsetStart, end = HeaderInsetEnd),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier.size(HeaderTapTarget),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(leadingIconRes),
+ contentDescription = leadingContentDescription,
+ modifier = Modifier.size(HeaderIconSize),
+ tint = leadingIconTint
+ )
+ }
+
+ // Same optical correction as the main header: the 44.dp tap target leaves more gap
+ // than the design wants between glyph and label.
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontSize = HeaderTextSize,
+ fontWeight = FontWeight.Medium,
+ color = colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .offset(x = (-6).dp)
+ .then(
+ if (onTitleClick != null) {
+ Modifier
+ .clip(HeaderClusterShape)
+ .pressScaleClickable(onClick = onTitleClick)
+ .padding(horizontal = 6.dp, vertical = 4.dp)
+ } else {
+ Modifier
+ }
+ )
+ )
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ content = actions
+ )
+ }
+}
+
+/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */
+@Composable
+fun ConversationHeaderAction(
+ onClick: () -> Unit,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit
+) = HeaderIconButton(
+ onClick = onClick,
+ contentDescription = contentDescription,
+ modifier = modifier,
+ content = content
+)
+
@Composable
fun NicknameEditor(
value: String,
@@ -486,51 +610,27 @@ private fun ChannelHeader(
) {
val colorScheme = MaterialTheme.colorScheme
- Box(modifier = Modifier.fillMaxWidth()) {
- // Back: a chevron alone is unambiguous here and buys back ~40.dp of title space that
- // the old "< back" label consumed.
- HeaderIconButton(
+ // No back affordance: the close action on the right is the way out, exactly as in a private
+ // chat. Leaving the channel outright lives on its row in the network sheet, so it does not
+ // need a second, easily-mistaken home next to the exit.
+ ConversationHeader(
+ leadingIconRes = R.drawable.ic_spec_chat_bubbles,
+ leadingIconTint = colorScheme.primary,
+ leadingContentDescription = null,
+ title = "#$channel",
+ onTitleClick = onSidebarClick
+ ) {
+ ConversationHeaderAction(
onClick = onBackClick,
- contentDescription = stringResource(R.string.back),
- modifier = Modifier.align(Alignment.CenterStart)
+ contentDescription = stringResource(R.string.close_plain)
) {
Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = stringResource(R.string.back),
+ painter = painterResource(R.drawable.ic_spec_close),
+ contentDescription = stringResource(R.string.close_plain),
modifier = Modifier.size(HeaderIconSize),
- tint = colorScheme.primary
+ tint = colorScheme.onSurfaceVariant
)
}
-
- // Title - perfectly centered regardless of other elements
- Text(
- text = "#$channel",
- style = MaterialTheme.typography.titleMedium,
- fontSize = HeaderTextSize,
- color = colorScheme.primary,
- modifier = Modifier
- .align(Alignment.Center)
- .clip(HeaderClusterShape)
- .pressScaleClickable(onClick = onSidebarClick)
- .heightIn(min = HeaderTapTarget)
- .wrapContentHeight(Alignment.CenterVertically)
- .padding(horizontal = 10.dp)
- )
-
- // Leave button - positioned on the right
- Text(
- text = stringResource(R.string.chat_leave),
- style = MaterialTheme.typography.labelMedium,
- fontSize = 15.sp,
- color = colorScheme.error,
- modifier = Modifier
- .align(Alignment.CenterEnd)
- .clip(HeaderClusterShape)
- .pressScaleClickable(onClick = onLeaveChannel)
- .heightIn(min = HeaderTapTarget)
- .wrapContentHeight(Alignment.CenterVertically)
- .padding(horizontal = 10.dp)
- )
}
}
@@ -556,7 +656,10 @@ private fun MainHeader(
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(ChatHeaderHeight)
+ .padding(start = HeaderInsetStart, end = HeaderInsetEnd),
verticalAlignment = Alignment.CenterVertically
) {
// MARK: - Identity cluster.
@@ -615,7 +718,7 @@ private fun MainHeader(
contentDescription = stringResource(R.string.cd_unread_private_messages)
) {
Icon(
- imageVector = Icons.Filled.Email,
+ painter = painterResource(R.drawable.ic_spec_envelope),
contentDescription = stringResource(R.string.cd_unread_private_messages),
modifier = Modifier.size(HeaderIconSize),
tint = palette.accentOrange
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
index 60fd7d4a..e9c8e343 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
@@ -62,6 +62,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
+ val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
+ val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
@@ -197,6 +199,33 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}
+ val mentionPeerIdentities = remember(
+ displayMessages,
+ currentChannel,
+ selectedLocationChannel,
+ connectedPeers,
+ peerNicknames,
+ geohashPeople,
+ ) {
+ val knownPeers = if (
+ currentChannel == null && selectedLocationChannel is ChannelID.Location
+ ) {
+ val duplicateNames = duplicateGeohashBaseNames(geohashPeople)
+ geohashPeople.mapNotNull { person ->
+ if (isUnannouncedNickname(person.displayName)) return@mapNotNull null
+ val displayName = disambiguatedGeohashDisplayName(person, duplicateNames)
+ displayName to PeerIdentity.nostr(person.id)
+ }
+ } else {
+ connectedPeers.mapNotNull { peerID ->
+ peerNicknames[peerID]?.let { displayName ->
+ displayName to PeerIdentity.mesh(peerID)
+ }
+ }
+ }
+ buildMentionPeerIdentityMap(displayMessages, knownPeers)
+ }
+
// Determine whether to show media buttons (only hide in geohash location chats)
val showMediaButtons = when {
currentChannel != null -> true
@@ -238,6 +267,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
+ mentionPeerIdentities = mentionPeerIdentities,
modifier = Modifier.fillMaxSize(),
conversationKey = conversationKey,
contentPadding = PaddingValues(
@@ -350,6 +380,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
commandSuggestions = commandSuggestions,
showMentionSuggestions = showMentionSuggestions,
mentionSuggestions = mentionSuggestions,
+ mentionPeerIdentities = mentionPeerIdentities,
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
val commandText = viewModel.selectCommandSuggestion(suggestion)
messageText = TextFieldValue(
@@ -375,7 +406,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Floating header - positioned absolutely at top, ignores keyboard
ChatFloatingHeader(
- headerHeight = headerHeight,
selectedPrivatePeer = null,
currentChannel = currentChannel,
nickname = nickname,
@@ -558,6 +588,7 @@ fun ChatInputSection(
commandSuggestions: List,
showMentionSuggestions: Boolean,
mentionSuggestions: List,
+ mentionPeerIdentities: Map = emptyMap(),
onCommandSuggestionClick: (CommandSuggestion) -> Unit,
onMentionSuggestionClick: (String) -> Unit,
selectedPrivatePeer: String?,
@@ -624,6 +655,7 @@ fun ChatInputSection(
Column {
MentionSuggestionsBox(
suggestions = displayedMentionSuggestions,
+ mentionPeerIdentities = mentionPeerIdentities,
onSuggestionClick = onMentionSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
@@ -641,6 +673,7 @@ fun ChatInputSection(
currentChannel = currentChannel,
nickname = nickname,
showMediaButtons = showMediaButtons,
+ mentionPeerIdentities = mentionPeerIdentities,
modifier = Modifier.fillMaxWidth()
)
}
@@ -664,7 +697,6 @@ private const val BarBackgroundAlpha = 0.88f
private const val HeaderOpaqueStop = 0.72f
@Composable
private fun ChatFloatingHeader(
- headerHeight: Dp,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
@@ -696,38 +728,31 @@ private fun ChatFloatingHeader(
)
.windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area
) {
- // A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal
- // pad plus a 12.dp title inset and applies its own minimum heights, which made the
- // header's spacing impossible to specify exactly.
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(headerHeight)
- .padding(start = 12.dp, end = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- ChatHeaderContent(
- selectedPrivatePeer = selectedPrivatePeer,
- currentChannel = currentChannel,
- nickname = nickname,
- viewModel = viewModel,
- onBackClick = {
- when {
- selectedPrivatePeer != null -> viewModel.endPrivateChat()
- currentChannel != null -> viewModel.switchToChannel(null)
- }
- },
- onSidebarClick = onSidebarToggle,
- onTripleClick = onPanicClear,
- onShowAppInfo = onShowAppInfo,
- onLocationChannelsClick = onLocationChannelsClick,
- onLocationNotesClick = {
- // Ensure location is loaded before showing sheet
- locationManager.refreshChannels()
- onLocationNotesClick()
+ // No TopAppBar: it silently injects a 4.dp horizontal pad plus a 12.dp title inset and
+ // applies its own minimum heights, which made the header's spacing impossible to specify
+ // exactly. Height and edge insets belong to each header variant, so that a conversation
+ // header rendered here and one rendered in a sheet are laid out identically.
+ ChatHeaderContent(
+ selectedPrivatePeer = selectedPrivatePeer,
+ currentChannel = currentChannel,
+ nickname = nickname,
+ viewModel = viewModel,
+ onBackClick = {
+ when {
+ selectedPrivatePeer != null -> viewModel.endPrivateChat()
+ currentChannel != null -> viewModel.switchToChannel(null)
}
- )
- }
+ },
+ onSidebarClick = onSidebarToggle,
+ onTripleClick = onPanicClear,
+ onShowAppInfo = onShowAppInfo,
+ onLocationChannelsClick = onLocationChannelsClick,
+ onLocationNotesClick = {
+ // Ensure location is loaded before showing sheet
+ locationManager.refreshChannels()
+ onLocationNotesClick()
+ }
+ )
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
index 14676d07..d2ab06d3 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
@@ -10,7 +10,7 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatPalette
import com.bitchat.android.ui.theme.ChatVisualTokens
-import com.bitchat.android.ui.theme.colorForPeerSeed
+import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.*
@@ -31,6 +31,15 @@ internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha
/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */
internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha
+/**
+ * Mention token grammar shared by rendered messages and the composer.
+ *
+ * The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the
+ * same nickname. Keeping one regex prevents the composer from styling only `@name` while the
+ * rendered transcript styles the complete token.
+ */
+internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
+
/**
* Get RSSI-based color for signal strength visualization
*/
@@ -61,7 +70,7 @@ fun formatTextMessageSender(
val senderColor = if (isSelf) {
palette.accentOrange
} else {
- colorForPeerSeed(peerColorSeedForMessage(message), palette)
+ colorForPeer(peerIdentityForMessage(message), palette)
}
val senderWeight = FontWeight.SemiBold
val (baseName, suffix) = splitSuffix(message.sender)
@@ -193,6 +202,7 @@ fun formatTextMessageBody(
palette: BitchatPalette,
contentColor: Color,
linkColor: Color,
+ mentionPeerIdentities: Map = emptyMap(),
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
includeTimestamp: Boolean = true
): AnnotatedString {
@@ -205,6 +215,7 @@ fun formatTextMessageBody(
palette = palette,
contentColor = contentColor,
linkColor = linkColor,
+ mentionPeerIdentities = mentionPeerIdentities,
)
if (includeTimestamp) {
@@ -267,7 +278,7 @@ fun formatMessageHeaderAnnotatedString(
val baseColor = if (isSelf) {
palette.accentOrange
} else {
- colorForPeerSeed(peerColorSeedForMessage(message), palette)
+ colorForPeer(peerIdentityForMessage(message), palette)
}
val (baseName, suffix) = splitSuffix(message.sender)
@@ -326,6 +337,71 @@ fun splitSuffix(name: String): Pair {
return Pair(name, "")
}
+/**
+ * Build a case-insensitive mention-token lookup from canonical peer identities.
+ *
+ * Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when
+ * it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention
+ * as the wrong person.
+ */
+internal fun buildMentionPeerIdentityMap(
+ messages: List,
+ knownPeers: List> = emptyList(),
+): Map {
+ val candidates = linkedMapOf>()
+
+ fun add(displayName: String, identity: PeerIdentity) {
+ val normalizedName = displayName.trim().removePrefix("@")
+ if (normalizedName.isEmpty()) return
+
+ val (baseName, suffix) = splitSuffix(normalizedName)
+ val exactKey = normalizedName.lowercase(Locale.ROOT)
+ candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity)
+
+ if (suffix.isNotEmpty()) {
+ val baseKey = baseName.lowercase(Locale.ROOT)
+ candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity)
+ }
+ }
+
+ messages
+ .asSequence()
+ .filterNot { it.sender == "system" }
+ .forEach { add(it.sender, peerIdentityForMessage(it)) }
+ knownPeers.forEach { (displayName, identity) -> add(displayName, identity) }
+
+ return candidates.mapNotNull { (token, identities) ->
+ identities.singleOrNull()?.let { token to it }
+ }.toMap()
+}
+
+internal fun resolveMentionPeerIdentity(
+ mention: String,
+ mentionPeerIdentities: Map,
+): PeerIdentity? {
+ val mentionWithoutAt = mention.trim().removePrefix("@")
+ val baseName = splitSuffix(mentionWithoutAt).first
+ return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)]
+ ?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)]
+}
+
+/**
+ * Resolve the deterministic color for a mention on every surface that displays one.
+ *
+ * Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is
+ * unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID.
+ */
+internal fun colorForMention(
+ mention: String,
+ mentionPeerIdentities: Map,
+ palette: BitchatPalette,
+): Color {
+ val mentionWithoutAt = mention.trim().removePrefix("@")
+ val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities)
+ ?: PeerIdentity.nickname(mentionWithoutAt)
+ return colorForPeer(identity, palette)
+}
+
/**
* A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport
* may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as
@@ -356,13 +432,12 @@ private fun appendIOSFormattedContent(
palette: BitchatPalette,
contentColor: Color,
linkColor: Color,
+ mentionPeerIdentities: Map,
) {
- // iOS-style patterns: allow optional '#abcd' suffix in mentions
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
- val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
val hashtagMatches = hashtagPattern.findAll(content).toList()
- val mentionMatches = mentionPattern.findAll(content).toList()
+ val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList()
// Combine and sort matches, but exclude hashtags that overlap with mentions
val mentionRanges = mentionMatches.map { it.range }
@@ -462,10 +537,10 @@ private fun appendIOSFormattedContent(
val mentionColor = if (isMentionToMe) {
palette.accentOrange
} else {
- // Tint by the *mentioned* peer so a given name looks identical everywhere.
- colorForPeerSeed(
- PeerColorSeed(mentionWithoutAt.lowercase(Locale.ROOT)),
- palette
+ colorForMention(
+ mention = mentionWithoutAt,
+ mentionPeerIdentities = mentionPeerIdentities,
+ palette = palette,
)
}
val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA
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 87e6104e..72507d8b 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
@@ -355,17 +355,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 gh = geohashViewModel.conversationGeohash(convKey)
- if (!gh.isNullOrEmpty()) {
- com.bitchat.android.nostr.NostrBackgroundRuntime.ensureConversationDm(gh)
- }
- } catch (e: Exception) {
- Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}")
- }
+ geohashViewModel.ensureGeohashDMSubscriptionForConversation(convKey)
}
// MARK: - Channel Management (delegated)
@@ -931,6 +923,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.
@@ -1074,8 +1071,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
+ )
}
/**
@@ -1166,17 +1169,17 @@ class ChatViewModel(
}
}
- // MARK: - iOS-Compatible Color System
+ // MARK: - Canonical peer identities
/**
- * Get consistent color for a mesh peer by ID (iOS-compatible)
+ * Return the stable identity used by every UI surface to color a mesh peer.
*/
- fun peerColorSeedForMeshPeer(peerID: String): PeerColorSeed = meshPeerColorSeed(peerID)
+ fun peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID)
/**
- * Get consistent color for a Nostr pubkey (iOS-compatible)
+ * Return the stable identity used by every UI surface to color a Nostr peer.
*/
- fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed =
- geohashViewModel.peerColorSeedForNostrPubkey(pubkeyHex)
+ fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
+ geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt
index 4ac9344a..34b205af 100644
--- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt
+++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt
@@ -457,11 +457,20 @@ class CommandProcessor(
// Location channel: use geohash participants with collision-resistant suffixes
val geohashPeople = viewModel.geohashPeople.value
val currentNickname = state.getNicknameValue()
+ val duplicateNames = duplicateGeohashBaseNames(geohashPeople)
geohashPeople.mapNotNull { person ->
- val displayName = person.displayName
- // Exclude self from suggestions
- if (displayName.startsWith("${currentNickname}#")) {
+ val baseName = splitSuffix(person.displayName).first
+ val hasNicknameCollision =
+ baseName.lowercase(Locale.ROOT) in duplicateNames
+ val displayName = disambiguatedGeohashDisplayName(person, duplicateNames)
+ // A unique local nickname can be excluded directly. If it collides, the
+ // nickname alone cannot identify which row is self, so keep the suffixed
+ // rows rather than accidentally hiding the other user.
+ if (
+ !hasNicknameCollision &&
+ baseName.equals(currentNickname, ignoreCase = true)
+ ) {
null
} else {
displayName
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/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt
index 8f65bbb1..0cf88b74 100644
--- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt
+++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt
@@ -17,7 +17,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatFontFamily
-import com.bitchat.android.ui.theme.colorForPeerSeed
+import com.bitchat.android.ui.theme.colorForPeer
import com.bitchat.android.R
import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.util.*
@@ -91,13 +91,8 @@ fun GeohashPeopleList(
val teleportedPersonIds = remember(sections.teleportedIn) {
sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) }
}
- val baseNameCounts = remember(displayedPeople) {
- buildMap {
- displayedPeople.forEach { person ->
- val baseName = splitSuffix(person.displayName).first
- put(baseName, (get(baseName) ?: 0) + 1)
- }
- }
+ val duplicateBaseNames = remember(displayedPeople) {
+ duplicateGeohashBaseNames(displayedPeople)
}
Column(modifier = modifier) {
@@ -141,7 +136,9 @@ fun GeohashPeopleList(
hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"),
isTeleported = personIsTeleported,
viewModel = viewModel,
- showHashSuffix = (baseNameCounts[splitSuffix(person.displayName).first] ?: 0) > 1,
+ showHashSuffix = splitSuffix(person.displayName)
+ .first
+ .lowercase(Locale.ROOT) in duplicateBaseNames,
onTap = {
if (!isMe) {
viewModel.startGeohashDM(person.id)
@@ -175,6 +172,39 @@ internal data class GeohashPeopleSections(
val teleportedIn: List
)
+/**
+ * Names that require a short identity suffix, calculated across both people sections.
+ *
+ * Matching is case-insensitive to mirror geohash chat's nickname collision handling.
+ */
+internal fun duplicateGeohashBaseNames(people: List): Set =
+ people
+ .groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) }
+ .eachCount()
+ .filterValues { it > 1 }
+ .keys
+
+/**
+ * The same `#abcd` disambiguator used by geohash chat.
+ *
+ * Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr
+ * public key when a collision exists. Preserve an already-announced suffix for compatibility.
+ */
+internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String {
+ if (!showHashSuffix) return ""
+ val announcedSuffix = splitSuffix(person.displayName).second
+ return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" }
+}
+
+internal fun disambiguatedGeohashDisplayName(
+ person: GeoPerson,
+ duplicateBaseNames: Set,
+): String {
+ val baseName = splitSuffix(person.displayName).first
+ val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames
+ return baseName + geohashIdentitySuffix(person, showSuffix)
+}
+
/**
* Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities
* are omitted, while announced names such as `anon1234` remain ordinary participants. Self is
@@ -249,11 +279,11 @@ private fun GeohashPersonItem(
if (isTeleported) R.drawable.ic_spec_teleport
else R.drawable.ic_spec_on_location_person
- val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName)
+ val (baseNameRaw, _) = splitSuffix(person.displayName)
val baseName = truncateNickname(baseNameRaw)
- val suffix = if (showHashSuffix) suffixRaw else ""
- val assignedColor = colorForPeerSeed(
- viewModel.peerColorSeedForNostrPubkey(person.id),
+ val suffix = geohashIdentitySuffix(person, showHashSuffix)
+ val assignedColor = colorForPeer(
+ viewModel.peerIdentityForNostrPubkey(person.id),
palette
)
val baseColor = if (isMe) palette.accentOrange else assignedColor
@@ -314,7 +344,7 @@ private fun GeohashPersonItem(
text = suffix,
fontFamily = BitchatFontFamily,
fontSize = 14.sp,
- fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
+ fontWeight = FontWeight.Normal,
color = baseColor.copy(alpha = SUFFIX_ALPHA)
)
}
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 2e74f3e0..dbc312b5 100644
--- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
+++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
@@ -8,10 +8,12 @@ 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
import com.bitchat.android.nostr.NostrBackgroundRuntime
+import com.bitchat.android.nostr.NostrDirectMessageHandler
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.nostr.NostrProtocol
import com.bitchat.android.nostr.NostrRelayManager
@@ -25,7 +27,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.Date
import kotlinx.coroutines.isActive
-import kotlinx.coroutines.Dispatchers
+import java.util.UUID
class GeohashViewModel(
application: Application,
@@ -69,6 +71,19 @@ class GeohashViewModel(
private var geoTimer: 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 = {
+ val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet()
+ revokedLiveGeohashes.forEach { geohash ->
+ samplingSubscriptionIds.remove(geohash)
+ activeSamplingGeohashes.remove(geohash)
+ }
+ liveSamplingSubscriptionGeohashes.clear()
+ requestedLiveSamplingGeohashes = emptySet()
+ }
// Geohash of the currently selected Location channel (null for Mesh/none).
private var activeChannelGeohash: String? = null
@@ -77,6 +92,10 @@ class GeohashViewModel(
val geohashParticipantCounts: StateFlow