mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
fix: enforce soft location privacy gate
This commit is contained in:
parent
bc49c71ea0
commit
09c5c74c09
@ -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) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user