diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt index 41ef168d..a475713a 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannel.kt @@ -36,7 +36,18 @@ data class GeohashChannel( */ sealed class ChannelID { object Mesh : ChannelID() - data class Location(val channel: GeohashChannel) : ChannelID() + data class Location(val channel: GeohashChannel) : ChannelID() { + companion object { + fun fromPersisted(levelName: String, geohash: String): Location? { + return try { + val level = GeohashChannelLevel.valueOf(levelName) + Location(GeohashChannel(level, geohash)) + } catch (_: IllegalArgumentException) { + null + } + } + } + } /** * Human readable name for UI. diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index 3e74a8ee..e80a7966 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -1,7 +1,6 @@ package com.bitchat.android.geohash import android.Manifest -import android.content.Context import android.content.pm.PackageManager import android.location.Geocoder import android.location.Location @@ -15,7 +14,10 @@ import java.util.* import com.google.gson.Gson import com.google.gson.JsonSyntaxException import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn /** * Manages location permissions, one-shot location retrieval, and computing geohash channels. @@ -38,22 +40,21 @@ class LocationChannelManager private constructor(private val context: Context) { // State enum matching iOS enum class PermissionState { - NOT_DETERMINED, DENIED, - RESTRICTED, AUTHORIZED } private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager private val geocoder: Geocoder = Geocoder(context, Locale.getDefault()) private var lastLocation: Location? = null - private var refreshTimer: Job? = null - private var isGeocoding: Boolean = false + private var geocodingJob: Job? = null private val gson = Gson() private var dataManager: com.bitchat.android.ui.DataManager? = null // Published state for UI bindings (matching iOS @Published properties) - private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private val _permissionState = MutableStateFlow(PermissionState.DENIED) val permissionState: StateFlow = _permissionState private val _availableChannels = MutableStateFlow>(emptyList()) @@ -74,6 +75,21 @@ class LocationChannelManager private constructor(private val context: Context) { private val _locationServicesEnabled = MutableStateFlow(false) val locationServicesEnabled: StateFlow = _locationServicesEnabled + private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled()) + val systemLocationEnabled: StateFlow = _systemLocationEnabled + + val effectiveLocationEnabled: StateFlow = combine( + locationServicesEnabled, + systemLocationEnabled + ) { appToggle, systemToggle -> + appToggle && systemToggle + }.stateIn( + scope, + SharingStarted.Eagerly, + false + ) + + init { updatePermissionState() // Initialize DataManager and load persisted settings @@ -91,26 +107,19 @@ class LocationChannelManager private constructor(private val context: Context) { fun enableLocationChannels() { Log.d(TAG, "enableLocationChannels() called") - // UNIFIED FIX: Check if location services are enabled by user - if (!isLocationServicesEnabled()) { - Log.w(TAG, "Location services disabled by user - not requesting location") + if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) { + Log.w(TAG, "Location services disabled (app or system) - not requesting location") return } - when (getCurrentPermissionStatus()) { - PermissionState.NOT_DETERMINED -> { - Log.d(TAG, "Permission not determined - user needs to grant in app settings") - _permissionState.value = PermissionState.NOT_DETERMINED - } - PermissionState.DENIED, PermissionState.RESTRICTED -> { - Log.d(TAG, "Permission denied or restricted") - _permissionState.value = PermissionState.DENIED - } - PermissionState.AUTHORIZED -> { - Log.d(TAG, "Permission authorized - requesting location") - _permissionState.value = PermissionState.AUTHORIZED - requestOneShotLocation() - } + + if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) { + Log.d(TAG, "Permission authorized - requesting location") + _permissionState.value = PermissionState.AUTHORIZED + requestOneShotLocation() + } else { + Log.d(TAG, "Permission not granted") + _permissionState.value = PermissionState.DENIED } } @@ -136,14 +145,10 @@ class LocationChannelManager private constructor(private val context: Context) { } if (!isLocationServicesEnabled()) { - Log.w(TAG, "Cannot start live refresh - location services disabled by user") + Log.w(TAG, "Cannot start live refresh - location services disabled") return } - // Cancel any existing timer-based refreshers - refreshTimer?.cancel() - refreshTimer = null - // Register for continuous updates from available providers try { if (hasLocationPermission()) { @@ -180,8 +185,6 @@ class LocationChannelManager private constructor(private val context: Context) { */ fun endLiveRefresh() { Log.d(TAG, "Ending live refresh") - refreshTimer?.cancel() - refreshTimer = null // Unregister continuous updates listener try { locationManager.removeUpdates(continuousLocationListener) @@ -235,8 +238,8 @@ class LocationChannelManager private constructor(private val context: Context) { _locationServicesEnabled.value = true saveLocationServicesState(true) - // If we have permission, start location operations - if (_permissionState.value == PermissionState.AUTHORIZED) { + // If we have permission and system location is on, start location operations + if (_permissionState.value == PermissionState.AUTHORIZED && systemLocationEnabled.value) { requestOneShotLocation() } } @@ -265,8 +268,11 @@ class LocationChannelManager private constructor(private val context: Context) { /** * Check if location services are enabled by the user */ + /** + * Check if both the app toggle and system location are enabled + */ fun isLocationServicesEnabled(): Boolean { - return _locationServicesEnabled.value + return _locationServicesEnabled.value && _systemLocationEnabled.value } // MARK: - Location Operations @@ -451,16 +457,10 @@ class LocationChannelManager private constructor(private val context: Context) { // MARK: - Helpers private fun getCurrentPermissionStatus(): PermissionState { - return when { - ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED -> { - PermissionState.AUTHORIZED - } - ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED -> { - PermissionState.AUTHORIZED - } - else -> { - PermissionState.DENIED // In Android, we can't distinguish between denied and not determined after first ask - } + return if (hasLocationPermission()) { + PermissionState.AUTHORIZED + } else { + PermissionState.DENIED } } @@ -519,33 +519,68 @@ class LocationChannelManager private constructor(private val context: Context) { return } - if (isGeocoding) { - Log.d(TAG, "Already geocoding, skipping") - return - } + // Cancel any pending geocoding job to avoid race conditions + geocodingJob?.cancel() - isGeocoding = true - - CoroutineScope(Dispatchers.IO).launch { + geocodingJob = scope.launch(Dispatchers.IO) { try { Log.d(TAG, "Starting reverse geocoding") - @Suppress("DEPRECATION") - val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1) - - if (!addresses.isNullOrEmpty()) { - val address = addresses[0] - val names = namesByLevel(address) - - Log.d(TAG, "Reverse geocoding result: $names") - _locationNames.value = names + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + // Use new listener-based API for Android 13+ + suspendCancellableCoroutine { cont -> + try { + geocoder.getFromLocation( + location.latitude, + location.longitude, + 1, + object : Geocoder.GeocodeListener { + override fun onGeocode(addresses: MutableList) { + if (!cont.isActive) return + if (addresses.isNotEmpty()) { + val address = addresses[0] + val names = namesByLevel(address) + Log.d(TAG, "Reverse geocoding result: $names") + _locationNames.value = names + } else { + Log.w(TAG, "No reverse geocoding results") + } + cont.resume(Unit) {} + } + + override fun onError(errorMessage: String?) { + if (!cont.isActive) return + Log.e(TAG, "Reverse geocoding failed: $errorMessage") + cont.resume(Unit) {} + } + } + ) + } catch (e: Exception) { + if (!cont.isActive) return@suspendCancellableCoroutine + Log.e(TAG, "Error initiating geocoding listener: ${e.message}") + cont.resume(Unit) {} + } + cont.invokeOnCancellation { + // Listener-based API has no explicit unregister, so we just ignore callbacks + } + } } else { - Log.w(TAG, "No reverse geocoding results") + @Suppress("DEPRECATION") + val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1) + + if (!addresses.isNullOrEmpty()) { + val address = addresses[0] + val names = namesByLevel(address) + Log.d(TAG, "Reverse geocoding result: $names") + _locationNames.value = names + } else { + Log.w(TAG, "No reverse geocoding results") + } } } catch (e: Exception) { - Log.e(TAG, "Reverse geocoding failed: ${e.message}") - } finally { - isGeocoding = false + if (e !is CancellationException) { + Log.e(TAG, "Reverse geocoding failed: ${e.message}") + } } } } @@ -599,18 +634,14 @@ class LocationChannelManager private constructor(private val context: Context) { private fun saveChannelSelection(channel: ChannelID) { try { val channelData = when (channel) { - is ChannelID.Mesh -> { - gson.toJson(mapOf("type" to "mesh")) - } - is ChannelID.Location -> { - gson.toJson(mapOf( - "type" to "location", - "level" to channel.channel.level.name, - "precision" to channel.channel.level.precision, - "geohash" to channel.channel.geohash, - "displayName" to channel.channel.level.displayName - )) - } + is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true)) + is ChannelID.Location -> gson.toJson( + PersistedChannel( + mesh = false, + level = channel.channel.level.name, + geohash = channel.channel.geohash + ) + ) } dataManager?.saveLastGeohashChannel(channelData) Log.d(TAG, "Saved channel selection: ${channel.displayName}") @@ -625,46 +656,14 @@ class LocationChannelManager private constructor(private val context: Context) { private fun loadPersistedChannelSelection() { try { val channelData = dataManager?.loadLastGeohashChannel() - if (channelData != null) { - val channelMap = gson.fromJson(channelData, Map::class.java) as? Map - if (channelMap != null) { - val channel = when (channelMap["type"] as? String) { - "mesh" -> ChannelID.Mesh - "location" -> { - val levelName = channelMap["level"] as? String - val precision = (channelMap["precision"] as? Double)?.toInt() - val geohash = channelMap["geohash"] as? String - val displayName = channelMap["displayName"] as? String - - if (levelName != null && precision != null && geohash != null && displayName != null) { - try { - val level = GeohashChannelLevel.valueOf(levelName) - val geohashChannel = GeohashChannel(level, geohash) - ChannelID.Location(geohashChannel) - } catch (e: IllegalArgumentException) { - Log.w(TAG, "Invalid geohash level in persisted data: $levelName") - null - } - } else { - Log.w(TAG, "Incomplete location channel data in persistence") - null - } - } - else -> { - Log.w(TAG, "Unknown channel type in persisted data: ${channelMap["type"]}") - null - } - } - - if (channel != null) { - _selectedChannel.value = channel - Log.d(TAG, "Restored persisted channel: ${channel.displayName}") - } else { - Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh") - _selectedChannel.value = ChannelID.Mesh - } + if (!channelData.isNullOrBlank()) { + val persisted = gson.fromJson(channelData, PersistedChannel::class.java) + val channel = persisted?.toChannel() + if (channel != null) { + _selectedChannel.value = channel + Log.d(TAG, "Restored persisted channel: ${channel.displayName}") } else { - Log.w(TAG, "Invalid channel data format in persistence") + Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh") _selectedChannel.value = ChannelID.Mesh } } else { @@ -679,7 +678,26 @@ class LocationChannelManager private constructor(private val context: Context) { _selectedChannel.value = ChannelID.Mesh } } + + + } + data class PersistedChannel( + val mesh: Boolean, + val level: String? = null, + val geohash: String? = null + ) { + fun toChannel(): ChannelID? { + return if (mesh) { + ChannelID.Mesh + } else { + val levelName = level ?: return null + val gh = geohash ?: return null + ChannelID.Location.fromPersisted(levelName, gh) + } + } + } + /** * Clear persisted channel selection (useful for testing or reset) */ @@ -724,6 +742,9 @@ class LocationChannelManager private constructor(private val context: Context) { Log.d(TAG, "Cleaning up LocationChannelManager") endLiveRefresh() + geocodingJob?.cancel() + geocodingJob = null + // Remove listeners to prevent memory leaks try { locationManager.removeUpdates(oneShotLocationListener) } catch (_: Exception) {} try { locationManager.removeUpdates(continuousLocationListener) } catch (_: Exception) {} diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index d3835a29..4442354c 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -63,7 +63,9 @@ fun LocationChannelsSheet( val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle() val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() - val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() + val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() + val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle() + val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle() // Observe bookmarks state val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() @@ -162,24 +164,7 @@ fun LocationChannelsSheet( verticalArrangement = Arrangement.spacedBy(4.dp) ) { when (permissionState) { - LocationChannelManager.PermissionState.NOT_DETERMINED -> { - Button( - onClick = { locationManager.enableLocationChannels() }, - colors = ButtonDefaults.buttonColors( - containerColor = standardGreen.copy(alpha = 0.12f), - contentColor = standardGreen - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.grant_location_permission), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - LocationChannelManager.PermissionState.DENIED, - LocationChannelManager.PermissionState.RESTRICTED -> { + LocationChannelManager.PermissionState.DENIED -> { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = stringResource(R.string.location_permission_denied), @@ -562,17 +547,24 @@ fun LocationChannelsSheet( } } - // Lifecycle management: when presented, sample both nearby and bookmarked geohashes + // Lifecycle management: when presented, manage location updates + DisposableEffect(isPresented, permissionState, locationServicesEnabled) { + if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { + locationManager.refreshChannels() + locationManager.beginLiveRefresh() + } + + onDispose { + locationManager.endLiveRefresh() + } + } + + // Sampling management: update sampling when channels/bookmarks change LaunchedEffect(isPresented, availableChannels, bookmarks) { if (isPresented) { - if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - locationManager.refreshChannels() - locationManager.beginLiveRefresh() - } val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() viewModel.beginGeohashSampling(geohashes) } else { - locationManager.endLiveRefresh() viewModel.endGeohashSampling() } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 601d4c03..6ad9bdd8 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -38,7 +38,7 @@ fun LocationNotesButton( val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val locationManager = remember { LocationChannelManager.getInstance(context) } val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() - val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false) + val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED