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 7c645122..5f0ed439 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -71,12 +71,17 @@ class LocationChannelManager private constructor(private val context: Context) { // Add a new LiveData property to indicate when location is being fetched private val _isLoadingLocation = MutableLiveData(false) val isLoadingLocation: LiveData = _isLoadingLocation + + // Add a new LiveData property to track if location services are enabled by user + private val _locationServicesEnabled = MutableLiveData(false) + val locationServicesEnabled: LiveData = _locationServicesEnabled init { updatePermissionState() - // Initialize DataManager and load persisted channel selection + // Initialize DataManager and load persisted settings dataManager = com.bitchat.android.ui.DataManager(context) loadPersistedChannelSelection() + loadLocationServicesState() } // MARK: - Public API (matching iOS interface) @@ -108,7 +113,7 @@ class LocationChannelManager private constructor(private val context: Context) { * Refresh available channels from current location */ fun refreshChannels() { - if (_permissionState.value == PermissionState.AUTHORIZED) { + if (_permissionState.value == PermissionState.AUTHORIZED && isLocationServicesEnabled()) { requestOneShotLocation() } } @@ -123,6 +128,11 @@ class LocationChannelManager private constructor(private val context: Context) { Log.w(TAG, "Cannot start live refresh - permission not authorized") return } + + if (!isLocationServicesEnabled()) { + Log.w(TAG, "Cannot start live refresh - location services disabled by user") + return + } // Cancel existing timer refreshTimer?.cancel() @@ -130,7 +140,9 @@ class LocationChannelManager private constructor(private val context: Context) { // Start new timer with coroutines refreshTimer = CoroutineScope(Dispatchers.IO).launch { while (isActive) { - requestOneShotLocation() + if (isLocationServicesEnabled()) { + requestOneShotLocation() + } delay(interval) } } @@ -185,6 +197,48 @@ class LocationChannelManager private constructor(private val context: Context) { _teleported.postValue(teleported) } + /** + * Enable location services (user-controlled toggle) + */ + fun enableLocationServices() { + Log.d(TAG, "enableLocationServices() called by user") + _locationServicesEnabled.postValue(true) + saveLocationServicesState(true) + + // If we have permission, start location operations + if (_permissionState.value == PermissionState.AUTHORIZED) { + requestOneShotLocation() + } + } + + /** + * Disable location services (user-controlled toggle) + */ + fun disableLocationServices() { + Log.d(TAG, "disableLocationServices() called by user") + _locationServicesEnabled.postValue(false) + saveLocationServicesState(false) + + // Stop any ongoing location operations + endLiveRefresh() + + // Clear available channels when location is disabled + _availableChannels.postValue(emptyList()) + _locationNames.postValue(emptyMap()) + + // If user had a location channel selected, switch back to mesh + if (_selectedChannel.value is ChannelID.Location) { + select(ChannelID.Mesh) + } + } + + /** + * Check if location services are enabled by the user + */ + fun isLocationServicesEnabled(): Boolean { + return _locationServicesEnabled.value ?: false + } + // MARK: - Location Operations private fun requestOneShotLocation() { @@ -572,6 +626,34 @@ class LocationChannelManager private constructor(private val context: Context) { Log.d(TAG, "Cleared persisted channel selection") } + // MARK: - Location Services State Persistence + + /** + * Save location services enabled state to persistent storage + */ + private fun saveLocationServicesState(enabled: Boolean) { + try { + dataManager?.saveLocationServicesEnabled(enabled) + Log.d(TAG, "Saved location services state: $enabled") + } catch (e: Exception) { + Log.e(TAG, "Failed to save location services state: ${e.message}") + } + } + + /** + * Load persisted location services state from storage + */ + private fun loadLocationServicesState() { + try { + val enabled = dataManager?.isLocationServicesEnabled() ?: false + _locationServicesEnabled.postValue(enabled) + Log.d(TAG, "Loaded location services state: $enabled") + } catch (e: Exception) { + Log.e(TAG, "Failed to load location services state: ${e.message}") + _locationServicesEnabled.postValue(false) + } + } + /** * Cleanup resources */ diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt index 0ed7d690..4585b03f 100644 --- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt @@ -61,6 +61,17 @@ class DataManager(private val context: Context) { prefs.edit().remove("last_geohash_channel").apply() Log.d(TAG, "Cleared last geohash channel") } + + // MARK: - Location Services State + + fun saveLocationServicesEnabled(enabled: Boolean) { + prefs.edit().putBoolean("location_services_enabled", enabled).apply() + Log.d(TAG, "Saved location services enabled state: $enabled") + } + + fun isLocationServicesEnabled(): Boolean { + return prefs.getBoolean("location_services_enabled", false) + } // MARK: - Channel Data Management 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 79b752ce..66a86ef1 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material3.* +import androidx.compose.ui.text.font.FontWeight import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment @@ -20,7 +21,6 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.ui.theme.BASE_FONT_SIZE @@ -52,6 +52,7 @@ fun LocationChannelsSheet( val selectedChannel by locationManager.selectedChannel.observeAsState() val teleported by locationManager.teleported.observeAsState(false) val locationNames by locationManager.locationNames.observeAsState(emptyMap()) + val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) // CRITICAL FIX: Observe reactive participant counts for real-time updates val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap()) @@ -109,59 +110,76 @@ fun LocationChannelsSheet( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) - // Permission handling - 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 = "get location and my geohashes", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } - } - - LocationChannelManager.PermissionState.DENIED, - LocationChannelManager.PermissionState.RESTRICTED -> { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "location permission denied. enable in settings to use location channels.", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - - TextButton( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) - } + // Location Services Control - Show permission handling if enabled + if (locationServicesEnabled) { + 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 = "open settings", + text = "grant location permission", fontSize = 12.sp, fontFamily = FontFamily.Monospace ) } } - } - - LocationChannelManager.PermissionState.AUTHORIZED -> { - // Authorized - show channels below - } - - null -> { - // Loading state - CircularProgressIndicator() + + LocationChannelManager.PermissionState.DENIED, + LocationChannelManager.PermissionState.RESTRICTED -> { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "location permission denied. enable in settings to use location channels.", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = Color.Red.copy(alpha = 0.8f) + ) + + TextButton( + onClick = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } + ) { + Text( + text = "open settings", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + } + } + } + + LocationChannelManager.PermissionState.AUTHORIZED -> { + Text( + text = "✓ location permission granted", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = standardGreen + ) + } + + null -> { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.size(12.dp)) + Text( + text = "checking permissions...", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } } } @@ -185,8 +203,8 @@ fun LocationChannelsSheet( ) } - // Nearby options - if (availableChannels.isNotEmpty()) { + // Nearby options (only show if location services are enabled) + if (availableChannels.isNotEmpty() && locationServicesEnabled) { items(availableChannels) { channel -> val coverage = coverageString(channel.geohash.length) val nameBase = locationNames[channel.level] @@ -210,7 +228,7 @@ fun LocationChannelsSheet( } ) } - } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { + } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { item { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -348,28 +366,39 @@ fun LocationChannelsSheet( } } - // Footer action - remove location access - if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { - item { - Button( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) + // Location services toggle button + item { + Button( + onClick = { + if (locationServicesEnabled) { + locationManager.disableLocationServices() + } else { + locationManager.enableLocationServices() + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (locationServicesEnabled) { + Color.Red.copy(alpha = 0.08f) + } else { + standardGreen.copy(alpha = 0.12f) }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.Red.copy(alpha = 0.08f), - contentColor = Color(0xFFBF1A1A) - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "remove location access", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) - } + contentColor = if (locationServicesEnabled) { + Color(0xFFBF1A1A) + } else { + standardGreen + } + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = if (locationServicesEnabled) { + "disable location services" + } else { + "enable location services" + }, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) } } } @@ -380,12 +409,14 @@ fun LocationChannelsSheet( // Lifecycle management LaunchedEffect(isPresented) { if (isPresented) { - // Refresh channels when opening - if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { + // Refresh channels when opening (only if location services are enabled) + if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { locationManager.refreshChannels() } - // Begin periodic refresh while sheet is open - locationManager.beginLiveRefresh() + // Begin periodic refresh while sheet is open (only if location services are enabled) + if (locationServicesEnabled) { + locationManager.beginLiveRefresh() + } // Begin multi-channel sampling for counts val geohashes = availableChannels.map { it.geohash } @@ -398,7 +429,14 @@ fun LocationChannelsSheet( // React to permission changes LaunchedEffect(permissionState) { - if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { + if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { + locationManager.refreshChannels() + } + } + + // React to location services enable/disable + LaunchedEffect(locationServicesEnabled) { + if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { locationManager.refreshChannels() } }