fix: enforce soft location privacy gate

This commit is contained in:
callebtc 2026-07-27 15:45:27 +02:00
parent bc49c71ea0
commit 09c5c74c09
26 changed files with 1384 additions and 516 deletions

View File

@ -817,7 +817,7 @@ class MainActivity : OrientationAwareActivity() {
val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH)
if (geohash != null) {
Log.d("MainActivity", "Opening geohash chat #$geohash from notification")
Log.d("MainActivity", "Opening geohash chat from notification")
// Switch to the geohash channel - create appropriate geohash channel level
val level = when (geohash.length) {

View File

@ -14,27 +14,44 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
private val geocoder = Geocoder(context, Locale.getDefault())
private val TAG = "AndroidGeocoderProvider"
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
override suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long?
): List<Address> {
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<Address>) {
if (cont.isActive) cont.resume(addresses)
}
val startRequest = {
geocoder.getFromLocation(
latitude,
longitude,
maxResults,
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: MutableList<Address>) {
if (cont.isActive) cont.resume(addresses)
}
override fun onError(errorMessage: String?) {
if (cont.isActive) {
Log.e(TAG, "Geocode error: $errorMessage")
cont.resume(emptyList())
override fun onError(errorMessage: String?) {
if (cont.isActive) {
Log.e(TAG, "Geocode error")
cont.resume(emptyList())
}
}
}
}
)
)
}
val started = if (liveLocationToken == null) {
startRequest()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(
liveLocationToken,
startRequest
)
}
if (!started && cont.isActive) cont.resume(emptyList())
} catch (e: Exception) {
if (cont.isActive) cont.resumeWithException(e)
}
@ -42,9 +59,22 @@ class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
} else {
@Suppress("DEPRECATION")
try {
geocoder.getFromLocation(latitude, longitude, maxResults) ?: emptyList()
var addresses: List<Address> = emptyList()
val request = {
addresses = geocoder.getFromLocation(
latitude,
longitude,
maxResults
) ?: emptyList()
}
if (liveLocationToken == null) {
request()
} else {
LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, request)
}
addresses
} catch (e: Exception) {
Log.e(TAG, "Geocode failed", e)
Log.e(TAG, "Geocode failed")
emptyList()
}
}

View File

@ -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<CancellationTokenSource>()
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")
}
}
}

View File

@ -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<Address>
suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long? = null
): List<Address>
}

View File

@ -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)
}

View File

@ -0,0 +1,24 @@
package com.bitchat.android.geohash
internal object GeohashNostrPrivacyPolicy {
fun livePresenceTargets(
availableChannels: Collection<GeohashChannel>,
liveLocationEnabled: Boolean,
): Set<String> {
if (!liveLocationEnabled) return emptySet()
return availableChannels
.asSequence()
.filter { it.level.precision <= GeohashChannelLevel.CITY.precision }
.map { it.geohash }
.toSet()
}
fun samplingTargets(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
liveLocationEnabled: Boolean,
): Set<String> = buildSet {
addAll(userSelectedGeohashes)
if (liveLocationEnabled) addAll(liveLocationGeohashes)
}
}

View File

@ -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<Boolean> = _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<Boolean> = 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)
}
}
}

View File

@ -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<Boolean> = _isLoadingLocation
private val _locationServicesEnabled = MutableStateFlow(false)
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
val locationServicesEnabled: StateFlow<Boolean> = LiveLocationPrivacyGate.enabled
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
val systemLocationEnabled: StateFlow<Boolean> = _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<GeohashChannel>()
@ -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)
}
}

View File

@ -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.

View File

@ -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<Address> {
return withContext(Dispatchers.IO) {
override suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long?
): List<Address> {
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<Address>()
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<Address>()
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<Address>()
val address = mapToAddress(osmResponse, latitude, longitude)
listOf(address)
} catch (e: Exception) {
Log.e(TAG, "OSM Parse failed: ${e.message}")
emptyList<Address>()
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<Address>()
})
}
val started = if (liveLocationToken == null) {
enqueueRequest()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(
liveLocationToken,
enqueueRequest
)
}
if (!started && continuation.isActive) {
continuation.resume(emptyList())
}
}
}

View File

@ -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")
}
}
}

View File

@ -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 ->

View File

@ -2,6 +2,7 @@ package com.bitchat.android.nostr
import android.util.Log
import androidx.annotation.MainThread
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -89,13 +90,18 @@ class LocationNotesManager private constructor() {
private var relayLookup: (() -> NostrRelayManager)? = null
private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null
private var unsubscribeFunc: ((String) -> Unit)? = null
private var sendEventFunc: ((NostrEvent, List<String>?) -> Unit)? = null
private var sendEventFunc: ((NostrEvent, List<String>?, Long) -> Unit)? = null
private var deriveIdentityFunc: ((String) -> NostrIdentity)? = null
// Coroutine scope for background operations
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var liveLocationToken: Long? = null
private var subscribeRetryJob: Job? = null
private var initialLoadJob: Job? = null
init {
LiveLocationPrivacyGate.addRevocationListener(::stop)
}
/**
* Initialize dependencies
@ -104,7 +110,7 @@ class LocationNotesManager private constructor() {
relayManager: () -> NostrRelayManager,
subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String,
unsubscribe: (String) -> Unit,
sendEvent: (NostrEvent, List<String>?) -> Unit,
sendEvent: (NostrEvent, List<String>?, Long) -> Unit,
deriveIdentity: (String) -> NostrIdentity
) {
this.relayLookup = relayManager
@ -119,23 +125,28 @@ class LocationNotesManager private constructor() {
* iOS: Validates building-level precision (8 characters)
*/
fun setGeohash(newGeohash: String) {
val token = LiveLocationPrivacyGate.captureToken() ?: run {
stop()
return
}
val normalized = newGeohash.lowercase()
if (_geohash.value == normalized) {
Log.d(TAG, "Geohash unchanged, skipping: $normalized")
if (_geohash.value == normalized &&
liveLocationToken?.let(LiveLocationPrivacyGate::accepts) == true
) {
return
}
// Validate geohash (building-level precision: 8 chars) - matches iOS
if (!isValidBuildingGeohash(normalized)) {
Log.w(TAG, "LocationNotesManager: rejecting invalid geohash '$normalized' (expected 8 valid base32 chars)")
Log.w(TAG, "LocationNotesManager rejected an invalid building geohash")
return
}
Log.d(TAG, "Setting geohash: $normalized")
// Cancel existing subscription
cancel()
if (!LiveLocationPrivacyGate.accepts(token)) return
liveLocationToken = token
// Set loading state before clearing to prevent empty state flicker (iOS pattern)
_state.value = State.LOADING
@ -154,7 +165,7 @@ class LocationNotesManager private constructor() {
subscribedGeohashes = (neighbors + normalized).toSet()
// Start new subscriptions for all cells
subscribeAll()
subscribeAll(token)
}
/**
@ -170,16 +181,20 @@ class LocationNotesManager private constructor() {
* Refresh notes for current geohash
*/
fun refresh() {
val token = LiveLocationPrivacyGate.captureToken() ?: run {
stop()
return
}
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot refresh - no geohash set")
return
}
Log.d(TAG, "Refreshing notes for geohash: $currentGeohash")
// Cancel and restart subscriptions for current ±1 set
cancel()
if (!LiveLocationPrivacyGate.accepts(token)) return
liveLocationToken = token
_notes.value = emptyList()
noteIDs.clear()
_initialLoadComplete.value = false
@ -188,13 +203,17 @@ class LocationNotesManager private constructor() {
com.bitchat.android.geohash.Geohash.neighborsSamePrecision(currentGeohash)
} catch (_: Exception) { emptySet() }
subscribedGeohashes = (neighbors + currentGeohash).toSet()
subscribeAll()
subscribeAll(token)
}
/**
* Send a new location note
*/
fun send(content: String, nickname: String?) {
val token = LiveLocationPrivacyGate.captureToken() ?: run {
stop()
return
}
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot send note - no geohash set")
@ -209,16 +228,22 @@ class LocationNotesManager private constructor() {
// CRITICAL FIX: Get geo-specific relays for sending (matching iOS pattern)
// iOS: let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
val relays = try {
com.bitchat.android.nostr.RelayDirectory.closestRelaysForGeohash(currentGeohash, 5)
var relays: List<String> = emptyList()
try {
LiveLocationPrivacyGate.runIfAllowed(token) {
relays = RelayDirectory.closestRelaysForGeohash(currentGeohash, 5)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to lookup relays for geohash $currentGeohash: ${e.message}")
emptyList()
Log.e(TAG, "Failed to look up location-note relays")
}
if (!LiveLocationPrivacyGate.accepts(token)) {
stop()
return
}
// Check if we have relays (iOS pattern: guard !relays.isEmpty())
if (relays.isEmpty()) {
Log.w(TAG, "Send blocked - no geo relays for geohash: $currentGeohash")
Log.w(TAG, "Location-note send blocked because no relays are available")
_state.value = State.NO_RELAYS
_errorMessage.value = "No relays available"
return
@ -231,34 +256,40 @@ class LocationNotesManager private constructor() {
return
}
Log.d(TAG, "Sending note to geohash: $currentGeohash via ${relays.size} geo relays")
scope.launch {
try {
val identity = withContext(Dispatchers.IO) {
deriveIdentity(currentGeohash)
var identity: NostrIdentity? = null
val identityPrepared = withContext(Dispatchers.IO) {
LiveLocationPrivacyGate.runIfAllowed(token) {
identity = deriveIdentity(currentGeohash)
}
}
val event = withContext(Dispatchers.IO) {
val preparedIdentity = identity
if (!identityPrepared || preparedIdentity == null ||
!LiveLocationPrivacyGate.accepts(token)
) return@launch
val preparedEvent = withContext(Dispatchers.IO) {
NostrProtocol.createGeohashTextNote(
content = trimmed,
geohash = currentGeohash,
senderIdentity = identity,
nickname = nickname
)
content = trimmed,
geohash = currentGeohash,
senderIdentity = preparedIdentity,
nickname = nickname
)
}
if (!LiveLocationPrivacyGate.accepts(token)) return@launch
// Optimistic local echo - add note immediately to UI
val localNote = Note(
id = event.id,
pubkey = event.pubkey,
id = preparedEvent.id,
pubkey = preparedEvent.pubkey,
content = trimmed,
createdAt = event.createdAt,
createdAt = preparedEvent.createdAt,
nickname = nickname
)
if (!noteIDs.contains(event.id)) {
noteIDs.add(event.id)
if (!noteIDs.contains(preparedEvent.id)) {
noteIDs.add(preparedEvent.id)
val currentNotes = _notes.value ?: emptyList()
_notes.value = (currentNotes + localNote).sortedByDescending { it.createdAt }
@ -270,11 +301,12 @@ class LocationNotesManager private constructor() {
// CRITICAL FIX: Send to geo-specific relays (matching iOS pattern)
// iOS: dependencies.sendEvent(event, relays)
withContext(Dispatchers.IO) {
sendEventFunc?.invoke(event, relays)
val sent = withContext(Dispatchers.IO) {
LiveLocationPrivacyGate.runIfAllowed(token) {
sendEventFunc?.invoke(preparedEvent, relays, token)
}
}
Log.d(TAG, "✅ Note sent successfully to ${relays.size} geo relays: ${event.id.take(16)}...")
if (!sent) return@launch
// Clear any error messages on successful send
_errorMessage.value = null
@ -290,12 +322,16 @@ class LocationNotesManager private constructor() {
/**
* Subscribe to location notes for current geohash
*/
private fun subscribeAll() {
private fun subscribeAll(token: Long) {
subscribeRetryJob?.cancel()
subscribeRetryJob = null
initialLoadJob?.cancel()
initialLoadJob = null
if (!LiveLocationPrivacyGate.accepts(token)) {
stop()
return
}
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot subscribe - no geohash set")
@ -310,17 +346,20 @@ class LocationNotesManager private constructor() {
// Retry a few times in case initialization is racing the sheet open
subscribeRetryJob = scope.launch {
var attempts = 0
while (attempts < 10 && subscribeFunc == null) {
while (attempts < 10 &&
subscribeFunc == null &&
LiveLocationPrivacyGate.accepts(token)
) {
delay(300)
attempts++
}
val subNow = subscribeFunc
if (subNow != null) {
if (subNow != null && LiveLocationPrivacyGate.accepts(token)) {
// Try again now that dependencies are ready
subscribeAll()
subscribeAll(token)
} else {
// Give UI a chance to show empty state rather than spinner forever
if (!_initialLoadComplete.value!!) {
if (!_initialLoadComplete.value) {
_initialLoadComplete.value = true
_state.value = State.READY
}
@ -333,28 +372,33 @@ class LocationNotesManager private constructor() {
// Subscribe for each geohash in the ±1 set
subscribedGeohashes.forEach { gh ->
if (!LiveLocationPrivacyGate.accepts(token)) return
val filter = NostrFilter.geohashNotes(
geohash = gh,
since = null,
limit = 200
)
val subId = "location-notes-$gh"
Log.d(TAG, "📡 Subscribing to location notes: $subId")
try {
val id = subscribe(filter, subId) { event -> handleEvent(event) }
subscriptionIDs[gh] = id
var id: String? = null
LiveLocationPrivacyGate.runIfAllowed(token) {
id = subscribe(filter, subId) { event -> handleEvent(event) }
}
id?.let { subscriptionIDs[gh] = it }
} catch (e: Exception) {
Log.e(TAG, "Failed to subscribe for $gh: ${e.message}")
Log.e(TAG, "Failed to subscribe to location notes")
}
}
// Mark initial load complete after brief delay to allow relay responses
initialLoadJob = scope.launch {
delay(2000) // Wait 2 seconds for initial batch
if (_geohash.value == currentGeohash && !_initialLoadComplete.value) {
if (_geohash.value == currentGeohash &&
LiveLocationPrivacyGate.accepts(token) &&
!_initialLoadComplete.value
) {
_initialLoadComplete.value = true
_state.value = State.READY
Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)")
}
}
}
@ -363,6 +407,9 @@ class LocationNotesManager private constructor() {
* Handle incoming event from subscription
*/
private fun handleEvent(event: NostrEvent) {
val token = liveLocationToken
if (token == null || !LiveLocationPrivacyGate.accepts(token)) return
// Validate event
if (event.kind != NostrKind.TEXT_NOTE) {
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
@ -379,7 +426,6 @@ class LocationNotesManager private constructor() {
// Check if matches current geohash
val eventGeohash = geohashTag[1]
if (!subscribedGeohashes.contains(eventGeohash)) {
Log.v(TAG, "Ignoring event for non-subscribed geohash: $eventGeohash")
return
}
@ -406,8 +452,6 @@ class LocationNotesManager private constructor() {
val currentNotes = _notes.value ?: emptyList()
_notes.value = (currentNotes + note).sortedByDescending { it.createdAt }
Log.d(TAG, "Added note from ${note.displayName}")
// Trim if exceeds max
if (noteIDs.size > MAX_NOTES_IN_MEMORY) {
trimOldestNotes()
@ -456,7 +500,6 @@ class LocationNotesManager private constructor() {
if (subscriptionIDs.isNotEmpty()) {
subscriptionIDs.values.forEach { subId ->
try {
Log.d(TAG, "🚫 Canceling subscription: $subId")
unsubscribeFunc?.invoke(subId)
} catch (_: Exception) { }
}
@ -473,9 +516,10 @@ class LocationNotesManager private constructor() {
*/
fun stop() {
cancel()
liveLocationToken = null
_geohash.value = null
_notes.value = emptyList()
noteIDs.clear()
_geohash.value = null
_initialLoadComplete.value = false
_errorMessage.value = null
}

View File

@ -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")
}
/**

View File

@ -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<String, NostrIdentity>()
private val geohashIdentityCache = ConcurrentHashMap<String, NostrIdentity>()
/**
* 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<String>) {
geohashes.forEach(geohashIdentityCache::remove)
}
// MARK: - Private Methods

View File

@ -1,6 +1,7 @@
package com.bitchat.android.nostr
import android.util.Log
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -97,14 +98,20 @@ class NostrRelayManager private constructor() {
val handler: (NostrEvent) -> Unit,
val targetRelayUrls: Set<String>? = null, // null means all relays
val createdAt: Long = System.currentTimeMillis(),
val originGeohash: String? = null // used for logging and grouping
val liveLocationToken: Long? = null
)
// Event deduplication system
private val eventDeduplicator = NostrEventDeduplicator.getInstance()
// Message queue for reliability
private val messageQueue = mutableListOf<Pair<NostrEvent, List<String>>>()
private data class QueuedEvent(
val event: NostrEvent,
val targetRelays: List<String>,
val liveLocationToken: Long? = null
)
private val messageQueue = mutableListOf<QueuedEvent>()
private val messageQueueLock = Any()
// Coroutine scope for background operations
@ -122,27 +129,49 @@ class NostrRelayManager private constructor() {
// Per-geohash relay selection
private val geohashToRelays = ConcurrentHashMap<String, Set<String>>() // geohash -> relay URLs
private val liveGeohashTokens = ConcurrentHashMap<String, Long>()
private val liveLocationRelayTokens = ConcurrentHashMap<String, Long>()
private val nonLiveRelayUrls = ConcurrentHashMap.newKeySet<String>()
private val liveLocationConnectionJobs = ConcurrentHashMap.newKeySet<Job>()
// --- Public API for geohash-specific operation ---
/**
* Compute and connect to relays for a given geohash (nearest + optional defaults), cache the mapping.
*/
fun ensureGeohashRelaysConnected(geohash: String, nRelays: Int = 5, includeDefaults: Boolean = false) {
fun ensureGeohashRelaysConnected(
geohash: String,
nRelays: Int = 5,
includeDefaults: Boolean = false,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
try {
val nearest = RelayDirectory.closestRelaysForGeohash(geohash, nRelays)
val selected = if (includeDefaults) {
(nearest + Companion.defaultRelays()).toSet()
} else nearest.toSet()
if (selected.isEmpty()) {
Log.w(TAG, "No relays selected for geohash=$geohash")
Log.w(TAG, "No relays selected for a geohash")
return
}
geohashToRelays[geohash] = selected
Log.d(TAG, "Geohash $geohash using ${selected.size} relays")
ensureConnectionsFor(selected)
runNetworkAction(liveLocationToken) {
geohashToRelays[geohash] = selected
if (liveLocationToken == null) {
liveGeohashTokens.remove(geohash)
nonLiveRelayUrls.addAll(selected)
} else {
liveGeohashTokens[geohash] = liveLocationToken
selected.forEach { relayUrl ->
if (relayUrl !in nonLiveRelayUrls) {
liveLocationRelayTokens[relayUrl] = liveLocationToken
}
}
}
ensureConnectionsFor(selected, liveLocationToken)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to ensure relays for $geohash: ${e.message}")
Log.e(TAG, "Failed to ensure geohash relays")
}
}
@ -162,40 +191,107 @@ class NostrRelayManager private constructor() {
id: String = generateSubscriptionId(),
handler: (NostrEvent) -> Unit,
includeDefaults: Boolean = false,
nRelays: Int = 5
nRelays: Int = 5,
liveLocationToken: Long? = null
): String {
ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults)
if (!isNetworkActionAllowed(liveLocationToken)) return id
ensureGeohashRelaysConnected(
geohash,
nRelays,
includeDefaults,
liveLocationToken
)
if (!isNetworkActionAllowed(liveLocationToken)) return id
val relayUrls = getRelaysForGeohash(geohash)
return subscribe(
filter = filter,
id = id,
handler = handler,
targetRelayUrls = relayUrls
).also {
// update origin geohash for this subscription
activeSubscriptions[it]?.let { sub ->
activeSubscriptions[it] = sub.copy(originGeohash = geohash)
}
}
targetRelayUrls = relayUrls,
liveLocationToken = liveLocationToken
)
}
/**
* Send an event specifically to a geohash's relays (+ optional defaults).
*/
fun sendEventToGeohash(event: NostrEvent, geohash: String, includeDefaults: Boolean = false, nRelays: Int = 5) {
ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults)
fun sendEventToGeohash(
event: NostrEvent,
geohash: String,
includeDefaults: Boolean = false,
nRelays: Int = 5,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
ensureGeohashRelaysConnected(
geohash,
nRelays,
includeDefaults,
liveLocationToken
)
if (!isNetworkActionAllowed(liveLocationToken)) return
val relayUrls = getRelaysForGeohash(geohash)
if (relayUrls.isEmpty()) {
Log.w(TAG, "No target relays to send event for geohash=$geohash; falling back to defaults")
sendEvent(event, Companion.defaultRelays())
Log.w(TAG, "No target relays for geohash event; falling back to defaults")
sendEvent(event, Companion.defaultRelays(), liveLocationToken)
return
}
sendEvent(event, relayUrls)
sendEvent(event, relayUrls, liveLocationToken)
}
// --- Internal helpers ---
private fun ensureConnectionsFor(relayUrls: Set<String>) {
private fun isNetworkActionAllowed(liveLocationToken: Long?): Boolean =
liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken)
private fun runNetworkAction(
liveLocationToken: Long?,
action: () -> Unit
): Boolean = if (liveLocationToken == null) {
action()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, action)
}
private fun revokeLiveLocationAccess() {
liveLocationConnectionJobs.forEach(Job::cancel)
liveLocationConnectionJobs.clear()
val liveSubscriptionIds = activeSubscriptions.values
.filter { it.liveLocationToken != null }
.mapTo(mutableSetOf()) { it.id }
liveSubscriptionIds.forEach { id ->
activeSubscriptions.remove(id)
messageHandlers.remove(id)
}
subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds }
synchronized(messageQueueLock) {
messageQueue.removeAll { it.liveLocationToken != null }
}
liveGeohashTokens.keys.forEach(geohashToRelays::remove)
liveGeohashTokens.clear()
val liveOnlyRelayUrls = liveLocationRelayTokens.keys
.filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls }
liveOnlyRelayUrls.forEach { relayUrl ->
connections.remove(relayUrl)?.cancel()
}
synchronized(relaysList) {
relaysList.removeAll { it.url in liveOnlyRelayUrls }
}
liveLocationRelayTokens.clear()
updateRelaysList()
updateConnectionStatus()
}
private fun ensureConnectionsFor(
relayUrls: Set<String>,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
// Ensure relays are tracked for UI/status
relayUrls.forEach { url ->
if (relaysList.none { it.url == url }) {
@ -204,15 +300,22 @@ class NostrRelayManager private constructor() {
}
updateRelaysList()
scope.launch {
val job = scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
relayUrls.forEach { relayUrl ->
launch {
if (!connections.containsKey(relayUrl)) {
connectToRelay(relayUrl)
if (!connections.containsKey(relayUrl) &&
isNetworkActionAllowed(liveLocationToken)
) {
connectToRelay(relayUrl, liveLocationToken)
}
}
}
}
if (liveLocationToken != null) {
liveLocationConnectionJobs.add(job)
job.invokeOnCompletion { liveLocationConnectionJobs.remove(job) }
}
}
init {
@ -225,8 +328,10 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com"
)
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
nonLiveRelayUrls.addAll(defaultRelayUrls)
_relays.value = relaysList.toList()
updateConnectionStatus()
LiveLocationPrivacyGate.addRevocationListener(::revokeLiveLocationAccess)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
// Initialize with empty list as fallback
@ -239,12 +344,14 @@ class NostrRelayManager private constructor() {
* Connect to all configured relays
*/
fun connect() {
Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays")
scope.launch {
relaysList.forEach { relay ->
launch {
connectToRelay(relay.url)
val liveToken = liveLocationRelayTokens[relay.url]
?.takeIf { relay.url !in nonLiveRelayUrls }
if (liveToken == null || LiveLocationPrivacyGate.accepts(liveToken)) {
connectToRelay(relay.url, liveToken)
}
}
}
}
@ -257,8 +364,6 @@ class NostrRelayManager private constructor() {
* Disconnect from all relays
*/
fun disconnect() {
Log.i(TAG, "Disconnecting from all Nostr relays")
// Stop subscription validation
stopSubscriptionValidation()
@ -276,23 +381,28 @@ class NostrRelayManager private constructor() {
/**
* Send an event to specified relays (or all if none specified)
*/
fun sendEvent(event: NostrEvent, relayUrls: List<String>? = null) {
fun sendEvent(
event: NostrEvent,
relayUrls: List<String>? = null,
liveLocationToken: Long? = null
) {
val targetRelays = relayUrls ?: relaysList.map { it.url }
// Add to queue for reliability
synchronized(messageQueueLock) {
messageQueue.add(Pair(event, targetRelays))
}
// Attempt immediate send
scope.launch {
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
sendToRelay(event, webSocket, relayUrl)
val queued = runNetworkAction(liveLocationToken) {
synchronized(messageQueueLock) {
messageQueue.add(QueuedEvent(event, targetRelays, liveLocationToken))
}
scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
sendToRelay(event, webSocket, relayUrl, liveLocationToken)
}
}
}
}
if (!queued) return
}
/**
@ -303,21 +413,22 @@ class NostrRelayManager private constructor() {
filter: NostrFilter,
id: String = generateSubscriptionId(),
handler: (NostrEvent) -> Unit,
targetRelayUrls: List<String>? = null
targetRelayUrls: List<String>? = null,
liveLocationToken: Long? = null
): String {
// Store subscription info for persistent tracking
val subscriptionInfo = SubscriptionInfo(
id = id,
filter = filter,
handler = handler,
targetRelayUrls = targetRelayUrls?.toSet()
targetRelayUrls = targetRelayUrls?.toSet(),
liveLocationToken = liveLocationToken
)
activeSubscriptions[id] = subscriptionInfo
messageHandlers[id] = handler
// Send subscription to appropriate relays
sendSubscriptionToRelays(subscriptionInfo)
runNetworkAction(liveLocationToken) {
activeSubscriptions[id] = subscriptionInfo
messageHandlers[id] = handler
sendSubscriptionToRelays(subscriptionInfo)
}
return id
}
@ -326,32 +437,38 @@ class NostrRelayManager private constructor() {
* Send a subscription to the appropriate relays
*/
private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) {
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
val message = gson.toJson(request, NostrRequest::class.java)
scope.launch {
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch
val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList()
targetRelays.forEach { relayUrl ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
try {
val success = webSocket.send(message)
var success = false
runNetworkAction(subscriptionInfo.liveLocationToken) {
success = webSocket.send(message)
}
if (success) {
// Track subscription for this relay
val currentSubs = subscriptions[relayUrl] ?: emptySet()
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
} else {
Log.w(TAG, "Failed to send subscription to $relayUrl: WebSocket send failed")
Log.w(TAG, "Failed to send subscription: WebSocket send failed")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send subscription to $relayUrl: ${e.message}")
Log.e(TAG, "Failed to send subscription")
}
}
}
if (connections.isEmpty()) {
Log.w(TAG, "No relay connections available for subscription, will retry on reconnection")
Log.w(TAG, "⚠️ No relay connections available for subscription, will retry on reconnection")
}
}
}
@ -365,7 +482,6 @@ class NostrRelayManager private constructor() {
messageHandlers.remove(id)
if (subscriptionInfo == null) {
Log.w(TAG, "Attempted to unsubscribe from unknown subscription: $id")
return
}
@ -373,14 +489,20 @@ class NostrRelayManager private constructor() {
val message = gson.toJson(request, NostrRequest::class.java)
scope.launch {
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
subscriptions.replaceAll { _, ids -> ids - id }
return@launch
}
connections.forEach { (relayUrl, webSocket) ->
val currentSubs = subscriptions[relayUrl]
if (currentSubs?.contains(id) == true) {
try {
webSocket.send(message)
runNetworkAction(subscriptionInfo.liveLocationToken) {
webSocket.send(message)
}
subscriptions[relayUrl] = currentSubs - id
} catch (e: Exception) {
Log.e(TAG, "Failed to unsubscribe from $relayUrl: ${e.message}")
Log.e(TAG, "Failed to unsubscribe from relay")
}
}
}
@ -392,6 +514,9 @@ class NostrRelayManager private constructor() {
*/
fun retryConnection(relayUrl: String) {
val relay = relaysList.find { it.url == relayUrl } ?: return
val liveToken = liveLocationRelayTokens[relayUrl]
?.takeIf { relayUrl !in nonLiveRelayUrls }
if (!isNetworkActionAllowed(liveToken)) return
// Reset reconnection attempts
relay.reconnectAttempts = 0
@ -403,7 +528,7 @@ class NostrRelayManager private constructor() {
// Attempt immediate reconnection
scope.launch {
connectToRelay(relayUrl)
connectToRelay(relayUrl, liveToken)
}
}
@ -552,7 +677,7 @@ class NostrRelayManager private constructor() {
try {
val report = validateSubscriptionConsistency()
if (!report.isConsistent && report.connectedRelayCount > 0) {
Log.w(TAG, "Subscription inconsistencies detected: ${report.inconsistencies}")
Log.w(TAG, "Nostr subscription inconsistencies detected")
// Auto-repair: re-establish subscriptions for relays with missing ones
connections.forEach { (relayUrl, webSocket) ->
@ -564,7 +689,7 @@ class NostrRelayManager private constructor() {
val missingSubs = expectedSubs - currentSubs
if (missingSubs.isNotEmpty()) {
Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl")
Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions")
restoreSubscriptionsForRelay(relayUrl, webSocket)
}
}
@ -574,6 +699,7 @@ class NostrRelayManager private constructor() {
}
}
}
}
/**
@ -586,7 +712,13 @@ class NostrRelayManager private constructor() {
// MARK: - Private Methods
private suspend fun connectToRelay(urlString: String) {
private suspend fun connectToRelay(
urlString: String,
liveLocationToken: Long? = null
) {
val connectionToken = liveLocationToken
?.takeIf { urlString !in nonLiveRelayUrls }
if (!isNetworkActionAllowed(connectionToken)) return
// Skip if we already have a connection
if (connections.containsKey(urlString)) {
return
@ -597,31 +729,45 @@ class NostrRelayManager private constructor() {
.url(urlString)
.build()
val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString))
connections[urlString] = webSocket
runNetworkAction(connectionToken) {
val webSocket = httpClient.newWebSocket(
request,
RelayWebSocketListener(urlString, connectionToken)
)
connections[urlString] = webSocket
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}")
handleDisconnection(urlString, e)
Log.e(TAG, "Failed to create WebSocket connection")
handleDisconnection(urlString, e, liveLocationToken)
}
}
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) {
private fun sendToRelay(
event: NostrEvent,
webSocket: WebSocket,
relayUrl: String,
liveLocationToken: Long? = null
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
try {
val request = NostrRequest.Event(event)
val message = gson.toJson(request, NostrRequest::class.java)
val success = webSocket.send(message)
var success = false
runNetworkAction(liveLocationToken) {
success = webSocket.send(message)
}
if (success) {
// Update relay stats
val relay = relaysList.find { it.url == relayUrl }
relay?.messagesSent = (relay?.messagesSent ?: 0) + 1
updateRelaysList()
} else {
Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed")
Log.e(TAG, "Failed to send event: WebSocket send failed")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}")
Log.e(TAG, "Failed to send event")
}
}
@ -629,7 +775,7 @@ class NostrRelayManager private constructor() {
try {
val jsonElement = JsonParser.parseString(message)
if (!jsonElement.isJsonArray) {
Log.w(TAG, "Received non-array message from $relayUrl")
Log.w(TAG, "Received non-array message from relay")
return
}
@ -643,7 +789,10 @@ class NostrRelayManager private constructor() {
updateRelaysList()
// CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter
activeSubscriptions[response.subscriptionId]?.let { subInfo ->
val subscriptionInfo = activeSubscriptions[response.subscriptionId]
?: return
if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return
subscriptionInfo.let { subInfo ->
val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true }
if (!matches) {
// Do NOT call deduplicator here to allow the correct subscription to process it later
@ -657,12 +806,15 @@ class NostrRelayManager private constructor() {
val handler = messageHandlers[response.subscriptionId]
if (handler != null) {
scope.launch(Dispatchers.Main) {
handler(event)
if (isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) {
handler(event)
}
}
} else {
Log.d(TAG, "No handler for subscription ${response.subscriptionId}")
Log.w(TAG, "⚠️ No handler for Nostr subscription")
}
}
}
is NostrResponse.EndOfStoredEvents -> {
@ -673,29 +825,36 @@ class NostrRelayManager private constructor() {
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
if (!response.accepted) {
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
Log.println(level, TAG, "Event rejected by relay $relayUrl: ${response.message ?: "no reason"}")
Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}")
}
}
is NostrResponse.Notice -> {
Log.d(TAG, "Notice from $relayUrl: ${response.message}")
// No action needed
}
is NostrResponse.Unknown -> {
Log.d(TAG, "Unknown message type from $relayUrl")
// No action needed
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message from $relayUrl: ${e.message}")
Log.e(TAG, "Failed to parse relay message")
}
}
private fun handleDisconnection(relayUrl: String, error: Throwable) {
private fun handleDisconnection(
relayUrl: String,
error: Throwable,
liveLocationToken: Long? = null
) {
val connectionToken = liveLocationToken
?.takeIf { relayUrl !in nonLiveRelayUrls }
connections.remove(relayUrl)
// NOTE: Don't remove subscriptions here - keep them for restoration on reconnection
// subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss
updateRelayStatus(relayUrl, false, error)
if (!isNetworkActionAllowed(connectionToken)) return
// Check if this is a DNS error
val errorMessage = error.message?.lowercase() ?: ""
@ -705,7 +864,7 @@ class NostrRelayManager private constructor() {
val relay = relaysList.find { it.url == relayUrl }
if (relay?.lastError == null) {
Log.w(TAG, "Nostr relay DNS failure for $relayUrl - not retrying")
Log.w(TAG, "Nostr relay DNS failure; not retrying")
}
return
}
@ -716,7 +875,7 @@ class NostrRelayManager private constructor() {
// Stop attempting after max attempts
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Log.w(TAG, "Max reconnection attempts ($MAX_RECONNECT_ATTEMPTS) reached for $relayUrl")
Log.w(TAG, "Max Nostr relay reconnection attempts reached")
return
}
@ -728,12 +887,14 @@ class NostrRelayManager private constructor() {
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})")
Log.d(TAG, "Scheduling Nostr relay reconnection")
// Schedule reconnection
scope.launch {
delay(backoffInterval)
connectToRelay(relayUrl)
if (isNetworkActionAllowed(connectionToken)) {
connectToRelay(relayUrl, connectionToken)
}
}
}
@ -774,30 +935,34 @@ class NostrRelayManager private constructor() {
private fun restoreSubscriptionsForRelay(relayUrl: String, webSocket: WebSocket) {
val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo ->
// Include subscription if it targets all relays or specifically targets this relay
subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl)
isNetworkActionAllowed(subscriptionInfo.liveLocationToken) &&
(subscriptionInfo.targetRelayUrls == null ||
subscriptionInfo.targetRelayUrls.contains(relayUrl))
}
if (subscriptionsToRestore.isEmpty()) {
return
}
Log.d(TAG, "Restoring ${subscriptionsToRestore.size} subscriptions for relay: $relayUrl")
subscriptionsToRestore.forEach { subscriptionInfo ->
try {
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
val message = gson.toJson(request, NostrRequest::class.java)
val success = webSocket.send(message)
var success = false
runNetworkAction(subscriptionInfo.liveLocationToken) {
success = webSocket.send(message)
}
if (success) {
// Track subscription for this relay
val currentSubs = subscriptions[relayUrl] ?: emptySet()
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
} else {
Log.w(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: WebSocket send failed")
Log.w(TAG, "Failed to restore subscription: WebSocket send failed")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: ${e.message}")
Log.e(TAG, "Failed to restore subscription")
}
}
}
@ -805,10 +970,17 @@ class NostrRelayManager private constructor() {
/**
* WebSocket listener for relay connections
*/
private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() {
private inner class RelayWebSocketListener(
private val relayUrl: String,
private val liveLocationToken: Long?
) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(TAG, "Connected to Nostr relay: $relayUrl")
if (!isNetworkActionAllowed(liveLocationToken)) {
connections.remove(relayUrl)
webSocket.cancel()
return
}
updateRelayStatus(relayUrl, true)
// Restore all active subscriptions for this relay
@ -818,9 +990,16 @@ class NostrRelayManager private constructor() {
synchronized(messageQueueLock) {
val iterator = messageQueue.iterator()
while (iterator.hasNext()) {
val (event, targetRelays) = iterator.next()
if (relayUrl in targetRelays) {
sendToRelay(event, webSocket, relayUrl)
val queued = iterator.next()
if (relayUrl in queued.targetRelays &&
isNetworkActionAllowed(queued.liveLocationToken)
) {
sendToRelay(
queued.event,
webSocket,
relayUrl,
queued.liveLocationToken
)
}
}
}
@ -835,14 +1014,13 @@ class NostrRelayManager private constructor() {
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "Disconnected from Nostr relay $relayUrl: $code $reason")
val error = Exception("WebSocket closed: $code $reason")
handleDisconnection(relayUrl, error)
handleDisconnection(relayUrl, error, liveLocationToken)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}")
handleDisconnection(relayUrl, t)
Log.e(TAG, "Nostr WebSocket failure")
handleDisconnection(relayUrl, t, liveLocationToken)
}
}
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.nostr
import android.app.Application
import android.util.Log
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -28,18 +29,54 @@ class NostrSubscriptionManager(
}
/** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */
fun subscribeGeohashMessages(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
fun subscribeGeohashMessages(
geohash: String,
sinceMs: Long,
limit: Int,
id: String,
handler: (NostrEvent) -> Unit,
liveLocationToken: Long? = null
) {
scope.launch {
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return@launch
val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
relayManager.subscribeForGeohash(
geohash,
filter,
id,
handler,
includeDefaults = false,
nRelays = 5,
liveLocationToken = liveLocationToken
)
}
}
/** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */
fun subscribeGeohashPresence(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
fun subscribeGeohashPresence(
geohash: String,
sinceMs: Long,
limit: Int,
id: String,
handler: (NostrEvent) -> Unit,
liveLocationToken: Long? = null
) {
scope.launch {
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return@launch
val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit)
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
relayManager.subscribeForGeohash(
geohash,
filter,
id,
handler,
includeDefaults = false,
nRelays = 5,
liveLocationToken = liveLocationToken
)
}
}

View File

@ -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 {
}
}
}

View File

@ -354,42 +354,9 @@ class ChatViewModel(
/**
* Ensure Nostr DM subscription for a geohash conversation key if known
* Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline
*/
private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) {
try {
val repoField = GeohashViewModel::class.java.getDeclaredField("repo")
repoField.isAccessible = true
val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
val gh = repo.getConversationGeohash(convKey)
if (!gh.isNullOrEmpty()) {
val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager")
subMgrField.isAccessible = true
val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication())
val subId = "geo-dm-$gh"
val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId")
currentDmSubField.isAccessible = true
val currentId = currentDmSubField.get(geohashViewModel) as String?
if (currentId != subId) {
(currentId)?.let { subMgr.unsubscribe(it) }
currentDmSubField.set(geohashViewModel, subId)
subMgr.subscribeGiftWraps(
pubkey = identity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = subId,
handler = { event ->
val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler")
dmHandlerField.isAccessible = true
val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler
dmHandler.onGiftWrap(event, gh, identity)
}
)
}
}
} catch (e: Exception) {
Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}")
}
geohashViewModel.ensureGeohashDMSubscriptionForConversation(convKey)
}
// MARK: - Channel Management (delegated)
@ -955,6 +922,11 @@ class ChatViewModel(
fun panicClearAllData() {
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
try {
com.bitchat.android.geohash.LocationChannelManager
.getInstance(getApplication())
.disableLocationServices()
} catch (_: Exception) { }
// A pending one-shot downgrade confirmation must not survive panic or
// become actionable against the fresh post-wipe identity.
@ -1098,8 +1070,14 @@ class ChatViewModel(
/**
* Begin sampling multiple geohashes for participant activity
*/
fun beginGeohashSampling(geohashes: List<String>) {
geohashViewModel.beginGeohashSampling(geohashes)
fun beginGeohashSampling(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
) {
geohashViewModel.beginGeohashSampling(
liveLocationGeohashes = liveLocationGeohashes,
userSelectedGeohashes = userSelectedGeohashes
)
}
/**

View File

@ -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

View File

@ -8,6 +8,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.viewModelScope
import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.bitchat.android.nostr.GeohashMessageHandler
import com.bitchat.android.nostr.GeohashRepository
import com.bitchat.android.nostr.NostrDirectMessageHandler
@ -18,15 +20,18 @@ import com.bitchat.android.nostr.NostrSubscriptionManager
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.util.Date
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.isActive
import kotlinx.coroutines.Dispatchers
import java.security.SecureRandom
import java.util.UUID
import kotlin.random.asKotlinRandom
class GeohashViewModel(
@ -69,10 +74,17 @@ class GeohashViewModel(
// Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded.
private var currentGeohashPresenceSubId: String? = null
private var currentDmSubId: String? = null
private var currentDmGeohash: String? = null
private var geoTimer: Job? = null
private var globalPresenceJob: Job? = null
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
private val activeSamplingGeohashes = mutableSetOf<String>()
private var requestedLiveSamplingGeohashes: Set<String> = emptySet()
private var requestedUserSamplingGeohashes: Set<String> = emptySet()
private val liveLocationRevocationListener: () -> Unit = {
activeSamplingGeohashes.removeAll { it !in requestedUserSamplingGeohashes }
requestedLiveSamplingGeohashes = emptySet()
}
// Geohash of the currently selected Location channel (null for Mesh/none).
private var activeChannelGeohash: String? = null
@ -81,6 +93,10 @@ class GeohashViewModel(
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
init {
LiveLocationPrivacyGate.addRevocationListener(liveLocationRevocationListener)
}
fun initialize() {
subscriptionManager.connect()
// Observe process lifecycle to manage background sampling
@ -124,10 +140,13 @@ class GeohashViewModel(
private fun startGlobalPresenceHeartbeat() {
globalPresenceJob?.cancel()
globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
// Reactively restart heartbeat whenever available channels change
locationChannelManager?.availableChannels?.collectLatest { channels ->
// Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5
val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash }
val manager = locationChannelManager ?: return@launch
combine(
manager.availableChannels,
LiveLocationPrivacyGate.enabled
) { channels, enabled ->
GeohashNostrPrivacyPolicy.livePresenceTargets(channels, enabled)
}.collectLatest { targetGeohashes ->
if (targetGeohashes.isNotEmpty()) {
// Enter heartbeat loop for this set of channels
@ -145,8 +164,10 @@ class GeohashViewModel(
delay(stepDelay)
timeSpent += stepDelay
broadcastPresence(geohash)
broadcastLiveLocationPresence(geohash)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Global presence heartbeat error: ${e.message}")
}
@ -172,6 +193,7 @@ class GeohashViewModel(
currentGeohashMsgSubId = null
currentGeohashPresenceSubId = null
currentDmSubId = null
currentDmGeohash = null
activeChannelGeohash = null
geoTimer?.cancel()
geoTimer = null
@ -181,22 +203,52 @@ class GeohashViewModel(
initialize()
}
private suspend fun broadcastPresence(geohash: String) {
private suspend fun broadcastLiveLocationPresence(geohash: String) {
val manager = locationChannelManager ?: return
val token = LiveLocationPrivacyGate.captureToken() ?: return
val isCurrentLiveTarget = GeohashNostrPrivacyPolicy.livePresenceTargets(
manager.availableChannels.value,
liveLocationEnabled = true
).contains(geohash)
if (!isCurrentLiveTarget || !LiveLocationPrivacyGate.accepts(token)) return
try {
val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
val relayManager = NostrRelayManager.getInstance(getApplication())
// Presence is lightweight, send to geohash relays
relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5)
Log.v(TAG, "💓 Sent presence heartbeat for $geohash")
var identity: com.bitchat.android.nostr.NostrIdentity? = null
LiveLocationPrivacyGate.runIfAllowed(token) {
identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
}
val preparedIdentity = identity ?: return
if (!LiveLocationPrivacyGate.accepts(token)) return
val event = NostrProtocol.createGeohashPresenceEvent(geohash, preparedIdentity)
LiveLocationPrivacyGate.runIfAllowed(token) {
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(
event,
geohash,
includeDefaults = false,
nRelays = 5,
liveLocationToken = token
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to send presence for $geohash: ${e.message}")
Log.w(TAG, "Failed to send live-location presence")
}
}
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
viewModelScope.launch {
try {
val canUseChannel = locationChannelManager
?.canUseSelectedLocationChannel(channel) == true
if (!canUseChannel) {
Log.w(TAG, "Blocked message to a stale live-location channel")
return@launch
}
val isLiveDerived = locationChannelManager
?.isSelectedChannelLiveDerived(channel) == true
val liveLocationToken = locationChannelManager
?.liveLocationTokenForSelectedChannel(channel)
if (isLiveDerived && liveLocationToken == null) return@launch
val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}"
val pow = PoWPreferenceManager.getCurrentSettings()
val localMsg = com.bitchat.android.model.BitchatMessage(
@ -216,10 +268,17 @@ class GeohashViewModel(
}
try {
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
val teleported = state.isTeleported.value
val teleported = locationChannelManager?.teleported?.value
?: state.isTeleported.value
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
val relayManager = NostrRelayManager.getInstance(getApplication())
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
relayManager.sendEventToGeohash(
event,
channel.geohash,
includeDefaults = false,
nRelays = 5,
liveLocationToken = liveLocationToken
)
} finally {
// Ensure we stop the per-message mining animation regardless of success/failure
if (startedMining) {
@ -232,15 +291,22 @@ class GeohashViewModel(
}
}
fun beginGeohashSampling(geohashes: List<String>) {
if (geohashes.isEmpty()) {
endGeohashSampling()
return
}
// Diffing logic to avoid redundant REQ and leaks
fun beginGeohashSampling(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
) {
requestedLiveSamplingGeohashes = liveLocationGeohashes.toSet()
requestedUserSamplingGeohashes = userSelectedGeohashes.toSet()
reconcileSamplingSubscriptions()
}
private fun reconcileSamplingSubscriptions() {
val currentSet = activeSamplingGeohashes.toSet()
val newSet = geohashes.toSet()
val newSet = GeohashNostrPrivacyPolicy.samplingTargets(
liveLocationGeohashes = requestedLiveSamplingGeohashes,
userSelectedGeohashes = requestedUserSamplingGeohashes,
liveLocationEnabled = LiveLocationPrivacyGate.isEnabled
)
val toRemove = currentSet - newSet
val toAdd = newSet - currentSet
@ -265,6 +331,8 @@ class GeohashViewModel(
}
fun endGeohashSampling() {
requestedLiveSamplingGeohashes = emptySet()
requestedUserSamplingGeohashes = emptySet()
if (activeSamplingGeohashes.isEmpty()) return
Log.d(TAG, "🌍 Ending geohash sampling (cleaning up ${activeSamplingGeohashes.size} subs)")
@ -287,7 +355,7 @@ class GeohashViewModel(
GeohashConversationRegistry.set(convKey, gh)
}
onStartPrivateChat(convKey)
Log.d(TAG, "🗨️ Started geohash DM with ${pubkeyHex} -> ${convKey} (geohash=${gh})")
Log.d(TAG, "🗨️ Started geohash DM")
}
fun startGeohashDMByNickname(nickname: String, onStartPrivateChat: (String) -> Unit) {
@ -340,6 +408,16 @@ class GeohashViewModel(
locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") }
}
fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) {
val geohash = repo.getConversationGeohash(conversationKey) ?: return
if (currentDmGeohash == geohash && currentDmSubId != null) return
currentDmSubId?.let(subscriptionManager::unsubscribe)
currentDmSubId = null
currentDmGeohash = null
subscribeChannelDM(geohash)
}
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
@ -353,6 +431,7 @@ class GeohashViewModel(
currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null }
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
currentDmGeohash = null
when (channel) {
is com.bitchat.android.geohash.ChannelID.Mesh -> {
@ -364,7 +443,12 @@ class GeohashViewModel(
repo.refreshGeohashPeople()
}
is com.bitchat.android.geohash.ChannelID.Location -> {
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
if (locationChannelManager?.canUseSelectedLocationChannel(channel.channel) != true) {
Log.w(TAG, "Ignoring a stale live-location channel selection")
switchLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
return
}
Log.d(TAG, "📍 Switching to geohash channel")
activeChannelGeohash = channel.channel.geohash
repo.setCurrentGeohash(channel.channel.geohash)
repo.refreshGeohashPeople()
@ -375,19 +459,22 @@ class GeohashViewModel(
try {
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
// We don't update participant here anymore; presence loop handles it via Kind 20001
val teleported = state.isTeleported.value
val teleported = locationChannelManager?.teleported?.value
?: state.isTeleported.value
if (teleported) repo.markTeleported(identity.publicKeyHex)
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
startGeoParticipantsTimer()
val liveLocationToken = locationChannelManager
?.liveLocationTokenForSelectedChannel(channel.channel)
// Chat message stream (kind 20000) is low-volume; keep it alive even when
// backgrounded so geohash messages still arrive.
subscribeChannelMessages(channel.channel.geohash)
subscribeChannelMessages(channel.channel.geohash, liveLocationToken)
// Presence heartbeat firehose (kind 20001) is the high-volume data hog; only
// run it in the foreground. It is restored in onStart() and torn down in onStop().
if (isAppInForeground()) {
subscribeChannelPresence(channel.channel.geohash)
subscribeChannelPresence(channel.channel.geohash, liveLocationToken)
}
// Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is
// kept alive in the background so geohash DMs still arrive.
@ -405,14 +492,18 @@ class GeohashViewModel(
* Subscribe to the chat message stream (kind 20000) for a geohash channel.
* Low-volume; kept alive in the background so messages keep arriving.
*/
private fun subscribeChannelMessages(geohash: String) {
private fun subscribeChannelMessages(
geohash: String,
liveLocationToken: Long?
) {
val subId = "geohash-$geohash"; currentGeohashMsgSubId = subId
subscriptionManager.subscribeGeohashMessages(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = liveLocationToken
)
}
@ -421,14 +512,18 @@ class GeohashViewModel(
* High-volume; only used to refresh the participant list, so it is torn down in
* onStop() and restored in onStart() to cut background mobile data.
*/
private fun subscribeChannelPresence(geohash: String) {
private fun subscribeChannelPresence(
geohash: String,
liveLocationToken: Long?
) {
val subId = "geohash-presence-$geohash"; currentGeohashPresenceSubId = subId
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 3600000L,
limit = 200,
id = subId,
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = liveLocationToken
)
}
@ -437,18 +532,18 @@ class GeohashViewModel(
* Lightweight (filtered to our pubkey); kept alive in the background.
*/
private fun subscribeChannelDM(geohash: String) {
viewModelScope.launch {
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
subscriptionManager.subscribeGiftWraps(
pubkey = dmIdentity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = dmSubId,
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
}
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
val dmSubId = "geo-dm-${UUID.randomUUID()}"
currentDmSubId = dmSubId
currentDmGeohash = geohash
subscriptionManager.subscribeGiftWraps(
pubkey = dmIdentity.publicKeyHex,
sinceMs = System.currentTimeMillis() - 172800000L,
id = dmSubId,
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
)
// Also register alias in global registry for routing convenience
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
}
private fun startGeoParticipantsTimer() {
@ -465,13 +560,25 @@ class GeohashViewModel(
kotlin.runCatching {
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
}
LiveLocationPrivacyGate.removeRevocationListener(liveLocationRevocationListener)
}
override fun onStart(owner: LifecycleOwner) {
Log.d(TAG, "🌍 App foregrounded: resuming Nostr streaming")
// Restore the presence heartbeat firehose for the selected geohash channel.
// (The chat message stream is kept alive in the background, so it is not restored here.)
activeChannelGeohash?.let { subscribeChannelPresence(it) }
val selected = locationChannelManager?.selectedChannel?.value
val selectedLocation = selected as? com.bitchat.android.geohash.ChannelID.Location
if (selectedLocation != null &&
selectedLocation.channel.geohash == activeChannelGeohash &&
locationChannelManager?.canUseSelectedLocationChannel(selectedLocation.channel) == true
) {
subscribeChannelPresence(
selectedLocation.channel.geohash,
locationChannelManager
?.liveLocationTokenForSelectedChannel(selectedLocation.channel)
)
}
// Resume geohash sampling subscriptions
activeSamplingGeohashes.forEach { performSubscribeSampling(it) }
// Resume the participant-refresh polling timer if a geohash is selected
@ -502,13 +609,36 @@ class GeohashViewModel(
private fun performSubscribeSampling(geohash: String) {
// Sampling only needs participant counts, never message bodies, so it subscribes to
// presence heartbeats only (kind 20001) to keep the payload small.
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = "sampling-$geohash",
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
val subscribe = {
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = "sampling-$geohash",
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
)
}
if (geohash in requestedUserSamplingGeohashes) {
subscribe()
} else if (geohash in requestedLiveSamplingGeohashes) {
val isCurrentLiveTarget = locationChannelManager
?.availableChannels
?.value
?.any { it.geohash == geohash } == true
if (!isCurrentLiveTarget) return
val token = LiveLocationPrivacyGate.captureToken() ?: return
LiveLocationPrivacyGate.runIfAllowed(token) {
subscriptionManager.subscribeGeohashPresence(
geohash = geohash,
sinceMs = System.currentTimeMillis() - 86400000L,
limit = 200,
id = "sampling-$geohash",
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
liveLocationToken = token
)
}
}
}
private fun isAppInForeground(): Boolean {

View File

@ -39,6 +39,7 @@ import com.bitchat.android.nostr.geohashesForSampling
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
@ -236,10 +237,9 @@ fun LocationChannelsSheet(
}
},
onClick = {
// Selecting a suggested nearby channel is not a teleport
locationManager.setTeleported(false)
locationManager.select(ChannelID.Location(channel))
onDismiss()
if (locationManager.selectNearby(channel)) {
onDismiss()
}
}
)
}
@ -306,14 +306,13 @@ fun LocationChannelsSheet(
}
},
onClick = {
// For bookmarked selection, mark teleported based on regional membership
val inRegional = availableChannels.any { it.geohash == gh }
if (!inRegional && availableChannels.isNotEmpty()) {
locationManager.setTeleported(true)
} else {
locationManager.setTeleported(false)
}
locationManager.select(ChannelID.Location(channel))
locationManager.selectManual(
channel = channel,
teleported = !appLocationEnabled ||
availableChannels.isEmpty() ||
!inRegional
)
onDismiss()
}
)
@ -420,9 +419,7 @@ fun LocationChannelsSheet(
if (isValid) {
val level = levelForLength(normalized.length)
val channel = GeohashChannel(level = level, geohash = normalized)
// Mark this selection as a manual teleport
locationManager.setTeleported(true)
locationManager.select(ChannelID.Location(channel))
locationManager.selectManual(channel)
onDismiss()
} else {
customError = context.getString(R.string.invalid_geohash)
@ -480,19 +477,19 @@ fun LocationChannelsSheet(
) {
Button(
onClick = {
if (locationServicesEnabled) {
if (appLocationEnabled) {
locationManager.disableLocationServices()
} else {
locationManager.enableLocationServices()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = if (locationServicesEnabled) {
containerColor = if (appLocationEnabled) {
Color.Red.copy(alpha = 0.08f)
} else {
standardGreen.copy(alpha = 0.12f)
},
contentColor = if (locationServicesEnabled) {
contentColor = if (appLocationEnabled) {
Color(0xFFBF1A1A)
} else {
standardGreen
@ -501,7 +498,7 @@ fun LocationChannelsSheet(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (locationServicesEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services),
text = if (appLocationEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
@ -525,26 +522,40 @@ fun LocationChannelsSheet(
}
// Lifecycle management: when presented, manage location updates
DisposableEffect(isPresented, permissionState, locationServicesEnabled) {
if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels()
locationManager.beginLiveRefresh()
LifecycleResumeEffect(isPresented, appLocationEnabled, systemLocationEnabled) {
if (isPresented) {
val currentPermission = locationManager.syncPermissionState()
if (appLocationEnabled &&
systemLocationEnabled &&
currentPermission == LocationChannelManager.PermissionState.AUTHORIZED
) {
locationManager.beginLiveRefresh()
}
}
onDispose {
onPauseOrDispose {
locationManager.endLiveRefresh()
}
}
// Sampling management: update sampling when channels/bookmarks change
LaunchedEffect(isPresented, availableChannels, bookmarks, notesRevealed) {
LaunchedEffect(
isPresented,
availableChannels,
bookmarks,
appLocationEnabled,
notesRevealed
) {
if (isPresented) {
val geohashes = geohashesForSampling(
val liveLocationGeohashes = geohashesForSampling(
availableChannels = availableChannels,
bookmarks = bookmarks,
bookmarks = emptyList(),
notesRevealed = notesRevealed,
)
viewModel.beginGeohashSampling(geohashes)
viewModel.beginGeohashSampling(
liveLocationGeohashes = liveLocationGeohashes,
userSelectedGeohashes = bookmarks
)
} else {
viewModel.endGeohashSampling()
}

View File

@ -3,7 +3,6 @@ package com.bitchat.android.ui
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
@ -48,6 +47,5 @@ internal fun channelForGeohash(geohash: String): GeohashChannel {
internal fun navigateToGeohash(context: Context, geohash: String): Boolean =
runCatching {
val locationManager = LocationChannelManager.getInstance(context)
locationManager.setTeleported(true)
locationManager.select(ChannelID.Location(channelForGeohash(geohash)))
locationManager.selectManual(channelForGeohash(geohash))
}.isSuccess

View File

@ -138,7 +138,7 @@ class NotificationManager(
*/
fun setCurrentGeohash(geohash: String?) {
currentGeohash = geohash
Log.d(TAG, "Current geohash changed: $geohash")
Log.d(TAG, "Current geohash changed")
}
/**
@ -446,11 +446,11 @@ class NotificationManager(
val shouldNotify = isAppInBackground || (!isAppInBackground && currentGeohash != geohash)
if (!shouldNotify) {
Log.d(TAG, "Skipping geohash notification - app in foreground and viewing geohash $geohash")
Log.d(TAG, "Skipping geohash notification while viewing the channel")
return
}
Log.d(TAG, "Showing geohash notification for $geohash from $senderNickname (mention: $isMention, first: $isFirstMessage)")
Log.d(TAG, "Showing geohash notification (mention: $isMention, first: $isFirstMessage)")
val notification = GeohashNotification(
geohash = geohash,
@ -652,7 +652,7 @@ class NotificationManager(
showGeohashSummaryNotification()
}
Log.d(TAG, "Cleared notifications for geohash: $geohash")
Log.d(TAG, "Cleared notifications for geohash")
}
/**

View File

@ -0,0 +1,46 @@
package com.bitchat.android.geohash
import org.junit.Assert.assertEquals
import org.junit.Test
class GeohashNostrPrivacyPolicyTest {
@Test
fun `disabled live location retains only user-selected sampling targets`() {
val targets = GeohashNostrPrivacyPolicy.samplingTargets(
liveLocationGeohashes = listOf("live-city", "shared"),
userSelectedGeohashes = listOf("bookmark", "shared"),
liveLocationEnabled = false
)
assertEquals(setOf("bookmark", "shared"), targets)
}
@Test
fun `enabled live location combines and deduplicates sampling targets`() {
val targets = GeohashNostrPrivacyPolicy.samplingTargets(
liveLocationGeohashes = listOf("live-city", "shared"),
userSelectedGeohashes = listOf("bookmark", "shared"),
liveLocationEnabled = true
)
assertEquals(setOf("live-city", "bookmark", "shared"), targets)
}
@Test
fun `presence excludes live channels when disabled`() {
val channels = listOf(
GeohashChannel(GeohashChannelLevel.REGION, "region"),
GeohashChannel(GeohashChannelLevel.CITY, "city"),
GeohashChannel(GeohashChannelLevel.NEIGHBORHOOD, "neighborhood")
)
assertEquals(
emptySet<String>(),
GeohashNostrPrivacyPolicy.livePresenceTargets(channels, false)
)
assertEquals(
setOf("region", "city"),
GeohashNostrPrivacyPolicy.livePresenceTargets(channels, true)
)
}
}

View File

@ -0,0 +1,48 @@
package com.bitchat.android.geohash
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LiveLocationAccessPolicyTest {
@Test
fun `live location is disabled by default`() {
val policy = LiveLocationAccessPolicy()
assertFalse(policy.isEnabled)
assertNull(policy.captureToken())
}
@Test
fun `disable and re-enable never revives old work`() {
val policy = LiveLocationAccessPolicy(initialEnabled = true)
val oldToken = requireNotNull(policy.captureToken())
policy.update(false)
assertFalse(policy.accepts(oldToken))
policy.update(true)
val newToken = requireNotNull(policy.captureToken())
assertFalse(policy.accepts(oldToken))
assertTrue(policy.accepts(newToken))
}
@Test
fun `invalidation prevents a queued action from running`() {
val policy = LiveLocationAccessPolicy(initialEnabled = true)
val token = requireNotNull(policy.captureToken())
var ran = false
policy.invalidate()
val accepted = policy.runIfAllowed(token) { ran = true }
assertFalse(accepted)
assertFalse(ran)
assertNull(policy.captureToken())
policy.resumeAccess()
assertTrue(policy.captureToken() != null)
}
}