location manager improvements

This commit is contained in:
callebtc
2026-01-14 17:44:37 +07:00
parent dd856ac01f
commit 7605effdce
4 changed files with 165 additions and 141 deletions
@@ -36,7 +36,18 @@ data class GeohashChannel(
*/ */
sealed class ChannelID { sealed class ChannelID {
object Mesh : 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. * Human readable name for UI.
@@ -1,7 +1,6 @@
package com.bitchat.android.geohash package com.bitchat.android.geohash
import android.Manifest import android.Manifest
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.Geocoder import android.location.Geocoder
import android.location.Location import android.location.Location
@@ -15,7 +14,10 @@ import java.util.*
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonSyntaxException import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow 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. * 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 // State enum matching iOS
enum class PermissionState { enum class PermissionState {
NOT_DETERMINED,
DENIED, DENIED,
RESTRICTED,
AUTHORIZED AUTHORIZED
} }
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val geocoder: Geocoder = Geocoder(context, Locale.getDefault()) private val geocoder: Geocoder = Geocoder(context, Locale.getDefault())
private var lastLocation: Location? = null private var lastLocation: Location? = null
private var refreshTimer: Job? = null private var geocodingJob: Job? = null
private var isGeocoding: Boolean = false
private val gson = Gson() private val gson = Gson()
private var dataManager: com.bitchat.android.ui.DataManager? = null private var dataManager: com.bitchat.android.ui.DataManager? = null
// Published state for UI bindings (matching iOS @Published properties) // 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> = _permissionState val permissionState: StateFlow<PermissionState> = _permissionState
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList()) private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
@@ -74,6 +75,21 @@ class LocationChannelManager private constructor(private val context: Context) {
private val _locationServicesEnabled = MutableStateFlow(false) private val _locationServicesEnabled = MutableStateFlow(false)
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
val systemLocationEnabled: StateFlow<Boolean> = _systemLocationEnabled
val effectiveLocationEnabled: StateFlow<Boolean> = combine(
locationServicesEnabled,
systemLocationEnabled
) { appToggle, systemToggle ->
appToggle && systemToggle
}.stateIn(
scope,
SharingStarted.Eagerly,
false
)
init { init {
updatePermissionState() updatePermissionState()
// Initialize DataManager and load persisted settings // Initialize DataManager and load persisted settings
@@ -91,26 +107,19 @@ class LocationChannelManager private constructor(private val context: Context) {
fun enableLocationChannels() { fun enableLocationChannels() {
Log.d(TAG, "enableLocationChannels() called") Log.d(TAG, "enableLocationChannels() called")
// UNIFIED FIX: Check if location services are enabled by user if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) {
if (!isLocationServicesEnabled()) { Log.w(TAG, "Location services disabled (app or system) - not requesting location")
Log.w(TAG, "Location services disabled by user - not requesting location")
return return
} }
when (getCurrentPermissionStatus()) {
PermissionState.NOT_DETERMINED -> { if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) {
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") Log.d(TAG, "Permission authorized - requesting location")
_permissionState.value = PermissionState.AUTHORIZED _permissionState.value = PermissionState.AUTHORIZED
requestOneShotLocation() 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()) { 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 return
} }
// Cancel any existing timer-based refreshers
refreshTimer?.cancel()
refreshTimer = null
// Register for continuous updates from available providers // Register for continuous updates from available providers
try { try {
if (hasLocationPermission()) { if (hasLocationPermission()) {
@@ -180,8 +185,6 @@ class LocationChannelManager private constructor(private val context: Context) {
*/ */
fun endLiveRefresh() { fun endLiveRefresh() {
Log.d(TAG, "Ending live refresh") Log.d(TAG, "Ending live refresh")
refreshTimer?.cancel()
refreshTimer = null
// Unregister continuous updates listener // Unregister continuous updates listener
try { try {
locationManager.removeUpdates(continuousLocationListener) locationManager.removeUpdates(continuousLocationListener)
@@ -235,8 +238,8 @@ class LocationChannelManager private constructor(private val context: Context) {
_locationServicesEnabled.value = true _locationServicesEnabled.value = true
saveLocationServicesState(true) saveLocationServicesState(true)
// If we have permission, start location operations // If we have permission and system location is on, start location operations
if (_permissionState.value == PermissionState.AUTHORIZED) { if (_permissionState.value == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
requestOneShotLocation() requestOneShotLocation()
} }
} }
@@ -265,8 +268,11 @@ class LocationChannelManager private constructor(private val context: Context) {
/** /**
* Check if location services are enabled by the user * Check if location services are enabled by the user
*/ */
/**
* Check if both the app toggle and system location are enabled
*/
fun isLocationServicesEnabled(): Boolean { fun isLocationServicesEnabled(): Boolean {
return _locationServicesEnabled.value return _locationServicesEnabled.value && _systemLocationEnabled.value
} }
// MARK: - Location Operations // MARK: - Location Operations
@@ -451,16 +457,10 @@ class LocationChannelManager private constructor(private val context: Context) {
// MARK: - Helpers // MARK: - Helpers
private fun getCurrentPermissionStatus(): PermissionState { private fun getCurrentPermissionStatus(): PermissionState {
return when { return if (hasLocationPermission()) {
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED -> {
PermissionState.AUTHORIZED PermissionState.AUTHORIZED
} } else {
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED -> { PermissionState.DENIED
PermissionState.AUTHORIZED
}
else -> {
PermissionState.DENIED // In Android, we can't distinguish between denied and not determined after first ask
}
} }
} }
@@ -519,33 +519,68 @@ class LocationChannelManager private constructor(private val context: Context) {
return return
} }
if (isGeocoding) { // Cancel any pending geocoding job to avoid race conditions
Log.d(TAG, "Already geocoding, skipping") geocodingJob?.cancel()
return
}
isGeocoding = true geocodingJob = scope.launch(Dispatchers.IO) {
CoroutineScope(Dispatchers.IO).launch {
try { try {
Log.d(TAG, "Starting reverse geocoding") Log.d(TAG, "Starting reverse geocoding")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
// Use new listener-based API for Android 13+
suspendCancellableCoroutine<Unit> { cont ->
try {
geocoder.getFromLocation(
location.latitude,
location.longitude,
1,
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: MutableList<android.location.Address>) {
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 {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1) val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
if (!addresses.isNullOrEmpty()) { if (!addresses.isNullOrEmpty()) {
val address = addresses[0] val address = addresses[0]
val names = namesByLevel(address) val names = namesByLevel(address)
Log.d(TAG, "Reverse geocoding result: $names") Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.value = names _locationNames.value = names
} else { } else {
Log.w(TAG, "No reverse geocoding results") Log.w(TAG, "No reverse geocoding results")
} }
}
} catch (e: Exception) { } catch (e: Exception) {
if (e !is CancellationException) {
Log.e(TAG, "Reverse geocoding failed: ${e.message}") Log.e(TAG, "Reverse geocoding failed: ${e.message}")
} finally { }
isGeocoding = false
} }
} }
} }
@@ -599,18 +634,14 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun saveChannelSelection(channel: ChannelID) { private fun saveChannelSelection(channel: ChannelID) {
try { try {
val channelData = when (channel) { val channelData = when (channel) {
is ChannelID.Mesh -> { is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true))
gson.toJson(mapOf("type" to "mesh")) is ChannelID.Location -> gson.toJson(
} PersistedChannel(
is ChannelID.Location -> { mesh = false,
gson.toJson(mapOf( level = channel.channel.level.name,
"type" to "location", geohash = channel.channel.geohash
"level" to channel.channel.level.name, )
"precision" to channel.channel.level.precision, )
"geohash" to channel.channel.geohash,
"displayName" to channel.channel.level.displayName
))
}
} }
dataManager?.saveLastGeohashChannel(channelData) dataManager?.saveLastGeohashChannel(channelData)
Log.d(TAG, "Saved channel selection: ${channel.displayName}") Log.d(TAG, "Saved channel selection: ${channel.displayName}")
@@ -625,37 +656,9 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun loadPersistedChannelSelection() { private fun loadPersistedChannelSelection() {
try { try {
val channelData = dataManager?.loadLastGeohashChannel() val channelData = dataManager?.loadLastGeohashChannel()
if (channelData != null) { if (!channelData.isNullOrBlank()) {
val channelMap = gson.fromJson(channelData, Map::class.java) as? Map<String, Any> val persisted = gson.fromJson(channelData, PersistedChannel::class.java)
if (channelMap != null) { val channel = persisted?.toChannel()
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) { if (channel != null) {
_selectedChannel.value = channel _selectedChannel.value = channel
Log.d(TAG, "Restored persisted channel: ${channel.displayName}") Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
@@ -663,10 +666,6 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh") Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.value = ChannelID.Mesh _selectedChannel.value = ChannelID.Mesh
} }
} else {
Log.w(TAG, "Invalid channel data format in persistence")
_selectedChannel.value = ChannelID.Mesh
}
} else { } else {
Log.d(TAG, "No persisted channel found, defaulting to Mesh") Log.d(TAG, "No persisted channel found, defaulting to Mesh")
_selectedChannel.value = ChannelID.Mesh _selectedChannel.value = ChannelID.Mesh
@@ -680,6 +679,25 @@ class LocationChannelManager private constructor(private val context: Context) {
} }
} }
}
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) * 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") Log.d(TAG, "Cleaning up LocationChannelManager")
endLiveRefresh() endLiveRefresh()
geocodingJob?.cancel()
geocodingJob = null
// Remove listeners to prevent memory leaks // Remove listeners to prevent memory leaks
try { locationManager.removeUpdates(oneShotLocationListener) } catch (_: Exception) {} try { locationManager.removeUpdates(oneShotLocationListener) } catch (_: Exception) {}
try { locationManager.removeUpdates(continuousLocationListener) } catch (_: Exception) {} try { locationManager.removeUpdates(continuousLocationListener) } catch (_: Exception) {}
@@ -63,7 +63,9 @@ fun LocationChannelsSheet(
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle() val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.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 // Observe bookmarks state
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
@@ -162,24 +164,7 @@ fun LocationChannelsSheet(
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
when (permissionState) { when (permissionState) {
LocationChannelManager.PermissionState.NOT_DETERMINED -> { LocationChannelManager.PermissionState.DENIED -> {
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 -> {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text( Text(
text = stringResource(R.string.location_permission_denied), 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
LaunchedEffect(isPresented, availableChannels, bookmarks) { DisposableEffect(isPresented, permissionState, locationServicesEnabled) {
if (isPresented) { if (isPresented && permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels() locationManager.refreshChannels()
locationManager.beginLiveRefresh() locationManager.beginLiveRefresh()
} }
onDispose {
locationManager.endLiveRefresh()
}
}
// Sampling management: update sampling when channels/bookmarks change
LaunchedEffect(isPresented, availableChannels, bookmarks) {
if (isPresented) {
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList() val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
viewModel.beginGeohashSampling(geohashes) viewModel.beginGeohashSampling(geohashes)
} else { } else {
locationManager.endLiveRefresh()
viewModel.endGeohashSampling() viewModel.endGeohashSampling()
} }
} }
@@ -38,7 +38,7 @@ fun LocationNotesButton(
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val locationManager = remember { LocationChannelManager.getInstance(context) } val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() 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 // Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED