From ae489a243969c749cdb7e65f2c11141dcd6f097a Mon Sep 17 00:00:00 2001 From: liuyunming521520-cell Date: Wed, 9 Sep 2026 21:09:06 +0800 Subject: [PATCH 1/9] Fix fused location fallback lifecycle --- .../android/geohash/FusedLocationProvider.kt | 267 +++++++++++++----- 1 file changed, 198 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt index 06439c80..76febb60 100644 --- a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt @@ -17,14 +17,27 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro private const val TAG = "FusedLocationProvider" } - private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) - - // Map to keep track of callbacks to remove them later - private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>() - private val activeCurrentLocationRequests = mutableSetOf() + private val fusedLocationClient: FusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(context) + private val systemFallback = SystemLocationProvider(context) + + private val activeCallbacks = mutableMapOf<(Location) -> Unit, UpdateRegistration>() + private val activeOneShotRequests = mutableSetOf() + + private class PendingOneShot { + val cancellation = CancellationTokenSource() + var fallbackStarted = false + } + + private class UpdateRegistration( + val fusedCallback: LocationCallback, + val systemCallback: (Location) -> Unit + ) { + var fallbackStarted = false + } private fun hasLocationPermission(): Boolean { - return LiveLocationPrivacyGate.isEnabled && + return LiveLocationPrivacyGate.captureToken() != null && (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED ) @@ -40,15 +53,19 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro try { fusedLocationClient.lastLocation .addOnSuccessListener { location -> - callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) + if (location != null && hasLocationPermission()) { + callback(location) + } else { + systemFallback.getLastKnownLocation(callback) + } } - .addOnFailureListener { e -> + .addOnFailureListener { Log.e(TAG, "Error getting last-known fused location") - callback(null) + systemFallback.getLastKnownLocation(callback) } } catch (e: Exception) { - Log.e(TAG, "Exception getting last-known fused location") - callback(null) + Log.e(TAG, "Exception getting last-known fused location", e) + systemFallback.getLastKnownLocation(callback) } } @@ -59,33 +76,82 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro return } + val pending = PendingOneShot() + synchronized(activeOneShotRequests) { + activeOneShotRequests += pending + } + try { val request = CurrentLocationRequest.Builder() .setPriority(Priority.PRIORITY_HIGH_ACCURACY) - .setDurationMillis(30000) + .setDurationMillis(30_000L) .build() - val cancellation = CancellationTokenSource() - synchronized(activeCurrentLocationRequests) { - activeCurrentLocationRequests.add(cancellation) - } - - fusedLocationClient.getCurrentLocation(request, cancellation.token) + fusedLocationClient.getCurrentLocation(request, pending.cancellation.token) .addOnSuccessListener { location -> - callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) - } - .addOnFailureListener { e -> - Log.e(TAG, "Error getting fresh fused location") - callback(null) - } - .addOnCompleteListener { - synchronized(activeCurrentLocationRequests) { - activeCurrentLocationRequests.remove(cancellation) + if (location != null && hasLocationPermission()) { + completeOneShot(pending, callback, location) + } else { + startSystemOneShotFallback(pending, callback) } } + .addOnFailureListener { + Log.w(TAG, "Fused fresh location failed; using system fallback") + startSystemOneShotFallback(pending, callback) + } + .addOnCanceledListener { + startSystemOneShotFallback(pending, callback) + } } catch (e: Exception) { - Log.e(TAG, "Exception getting fresh fused location") - callback(null) + Log.w(TAG, "Fused fresh location could not start; using system fallback", e) + startSystemOneShotFallback(pending, callback) + } + } + + private fun startSystemOneShotFallback( + pending: PendingOneShot, + callback: (Location?) -> Unit + ) { + var deliverNull = false + var fallbackStartFailed = false + + synchronized(activeOneShotRequests) { + if (!activeOneShotRequests.contains(pending)) { + return + } + + if (!hasLocationPermission()) { + activeOneShotRequests.remove(pending) + deliverNull = true + } else if (!pending.fallbackStarted) { + pending.fallbackStarted = true + try { + systemFallback.requestFreshLocation { location -> + completeOneShot(pending, callback, location) + } + } catch (e: Exception) { + Log.w(TAG, "System location fallback could not start", e) + fallbackStartFailed = true + } + } + } + + when { + deliverNull -> callback(null) + fallbackStartFailed -> completeOneShot(pending, callback, null) + } + } + + private fun completeOneShot( + pending: PendingOneShot, + callback: (Location?) -> Unit, + location: Location? + ) { + val shouldDeliver = synchronized(activeOneShotRequests) { + activeOneShotRequests.remove(pending) + } + if (shouldDeliver) { + callback(location.takeIf { hasLocationPermission() }) } } @@ -97,66 +163,129 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro ) { if (!hasLocationPermission()) return + removeLocationUpdates(callback) + + lateinit var registration: UpdateRegistration + val fusedCallback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + if (isCurrentRegistration(callback, registration) && hasLocationPermission()) { + result.lastLocation?.let(callback) + } + } + } + val systemCallback: (Location) -> Unit = { location -> + if (isCurrentRegistration(callback, registration) && hasLocationPermission()) { + callback(location) + } + } + registration = UpdateRegistration( + fusedCallback = fusedCallback, + systemCallback = systemCallback + ) + try { val request = LocationRequest.Builder(intervalMs) .setMinUpdateDistanceMeters(minDistanceMeters) .setPriority(Priority.PRIORITY_HIGH_ACCURACY) .build() - val locationCallback = object : LocationCallback() { - override fun onLocationResult(result: LocationResult) { - if (LiveLocationPrivacyGate.isEnabled) { - result.lastLocation?.let { callback(it) } - } - } - } - synchronized(activeCallbacks) { - activeCallbacks[callback] = locationCallback + activeCallbacks[callback] = registration + fusedLocationClient.requestLocationUpdates( + request, + fusedCallback, + Looper.getMainLooper() + ) + .addOnSuccessListener { + Log.d(TAG, "Registered fused updates") + } + .addOnFailureListener { + Log.w(TAG, "Fused updates unavailable; using system location provider") + startSystemUpdatesFallback( + callback = callback, + registration = registration, + intervalMs = intervalMs, + minDistanceMeters = minDistanceMeters + ) + } } - - fusedLocationClient.requestLocationUpdates( - request, - locationCallback, - Looper.getMainLooper() - ) - Log.d(TAG, "Registered fused updates") - } catch (e: Exception) { - Log.e(TAG, "Error requesting fused updates") + Log.w(TAG, "Unable to register fused updates; using system location provider", e) + startSystemUpdatesFallback( + callback = callback, + registration = registration, + intervalMs = intervalMs, + minDistanceMeters = minDistanceMeters + ) } } - override fun removeLocationUpdates(callback: (Location) -> Unit) { - try { - val locationCallback = synchronized(activeCallbacks) { + private fun startSystemUpdatesFallback( + callback: (Location) -> Unit, + registration: UpdateRegistration, + intervalMs: Long, + minDistanceMeters: Float + ) { + synchronized(activeCallbacks) { + if (activeCallbacks[callback] !== registration || registration.fallbackStarted) { + return + } + if (!hasLocationPermission()) { activeCallbacks.remove(callback) + return } - if (locationCallback != null) { - fusedLocationClient.removeLocationUpdates(locationCallback) - Log.d(TAG, "Removed fused updates") + registration.fallbackStarted = true + try { + systemFallback.requestLocationUpdates( + intervalMs = intervalMs, + minDistanceMeters = minDistanceMeters, + callback = registration.systemCallback + ) + } catch (e: Exception) { + Log.w(TAG, "Unable to register system location fallback", e) } - } catch (e: Exception) { - Log.e(TAG, "Error removing fused updates") + } + } + + private fun isCurrentRegistration( + callback: (Location) -> Unit, + registration: UpdateRegistration + ): Boolean = synchronized(activeCallbacks) { + activeCallbacks[callback] === registration + } + + override fun removeLocationUpdates(callback: (Location) -> Unit) { + val registration = synchronized(activeCallbacks) { + activeCallbacks.remove(callback)?.also { + if (it.fallbackStarted) { + systemFallback.removeLocationUpdates(it.systemCallback) + } + } + } + + if (registration != null) { + runCatching { fusedLocationClient.removeLocationUpdates(registration.fusedCallback) } + .onFailure { Log.w(TAG, "Unable to remove fused updates", it) } } } override fun cancel() { - try { - synchronized(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") + val registrations = synchronized(activeCallbacks) { + activeCallbacks.values.toList().also { activeCallbacks.clear() } } + registrations.forEach { registration -> + runCatching { fusedLocationClient.removeLocationUpdates(registration.fusedCallback) } + .onFailure { Log.w(TAG, "Unable to remove fused updates", it) } + } + + synchronized(activeOneShotRequests) { + activeOneShotRequests.forEach { it.cancellation.cancel() } + activeOneShotRequests.clear() + // This instance owns the fallback provider, so it is safe to cancel all of its work here. + systemFallback.cancel() + } + Log.d(TAG, "Cancelled all fused location requests") } } + From e49daae8048d13b7451530980ce97db1675904d2 Mon Sep 17 00:00:00 2001 From: liuyunming521520-cell Date: Wed, 9 Sep 2026 21:09:08 +0800 Subject: [PATCH 2/9] Improve system location provider fallback --- .../android/geohash/SystemLocationProvider.kt | 502 ++++++++++++------ 1 file changed, 349 insertions(+), 153 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt index 80bbbe4a..4c63c698 100644 --- a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt @@ -10,6 +10,7 @@ import android.location.LocationManager import android.os.Build import android.os.Bundle import android.os.CancellationSignal +import android.os.SystemClock import android.util.Log import androidx.core.app.ActivityCompat @@ -17,19 +18,41 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr companion object { private const val TAG = "SystemLocationProvider" + private const val FRESH_LOCATION_TIMEOUT_MS = 30_000L } private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager private val handler = android.os.Handler(android.os.Looper.getMainLooper()) - - // Map to keep track of listeners to unregister them later + + // Map to keep track of listeners to unregister them later. 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 val activeOneShotRequests = mutableSetOf() + + private class PendingOneShot( + val callback: (Location?) -> Unit, + val providers: List, + val deadlineElapsedRealtime: Long + ) { + var nextProviderIndex = 0 + var cancellationSignal: CancellationSignal? = null + var listener: LocationListener? = null + var timeoutRunnable: Runnable? = null + var completed = false + } + + private data class ProviderAttempt( + val provider: String, + val timeoutMs: Long + ) + + private data class OneShotResources( + val cancellationSignal: CancellationSignal?, + val listener: LocationListener?, + val timeoutRunnable: Runnable? + ) private fun hasLocationPermission(): Boolean { - return LiveLocationPrivacyGate.isEnabled && + return LiveLocationPrivacyGate.captureToken() != null && (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED ) @@ -53,7 +76,7 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr } } } - callback(bestLocation.takeIf { LiveLocationPrivacyGate.isEnabled }) + callback(bestLocation.takeIf { hasLocationPermission() }) } catch (e: Exception) { Log.e(TAG, "Error getting last-known location") callback(null) @@ -67,100 +90,279 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr return } - try { - val providers = listOf( + val request = PendingOneShot( + callback = callback, + providers = listOf( LocationManager.GPS_PROVIDER, - LocationManager.NETWORK_PROVIDER, - LocationManager.PASSIVE_PROVIDER - ) + LocationManager.NETWORK_PROVIDER + ), + deadlineElapsedRealtime = SystemClock.elapsedRealtime() + FRESH_LOCATION_TIMEOUT_MS + ) - var providerFound = false - for (provider in providers) { - if (locationManager.isProviderEnabled(provider)) { - Log.d(TAG, "Requesting fresh location from $provider") - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - 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 - val timeoutRunnable = Runnable { - Log.w(TAG, "Location request timed out") - synchronized(activeOneShotListeners) { - val listener = activeOneShotListeners.remove(callback) - activeOneShotRunnables.remove(callback) - if (listener != null) { - try { - locationManager.removeUpdates(listener) - } catch (e: Exception) { - Log.e(TAG, "Error removing timed-out listener") - } - } - } - callback(null) - } + synchronized(activeOneShotRequests) { + activeOneShotRequests += request + } + startNextOneShotProvider(request) + } - val listener = object : LocationListener { - override fun onLocationChanged(location: Location) { - synchronized(activeOneShotListeners) { - activeOneShotListeners.remove(callback) - val runnable = activeOneShotRunnables.remove(callback) - if (runnable != null) { - handler.removeCallbacks(runnable) - } - } - try { - locationManager.removeUpdates(this) - } catch (e: Exception) { - Log.e(TAG, "Error removing updates in callback") - } - callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) - } - override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} - override fun onProviderEnabled(provider: String) {} - override fun onProviderDisabled(provider: String) {} + private fun startNextOneShotProvider(request: PendingOneShot) { + var shouldFinish = false + val attempt = synchronized(activeOneShotRequests) { + if (!isOneShotActiveLocked(request)) { + null + } else if (!hasLocationPermission()) { + shouldFinish = true + null + } else { + val remainingMs = request.deadlineElapsedRealtime - SystemClock.elapsedRealtime() + if (remainingMs <= 0L) { + shouldFinish = true + null + } else { + var nextAttempt: ProviderAttempt? = null + while (request.nextProviderIndex < request.providers.size && nextAttempt == null) { + val provider = request.providers[request.nextProviderIndex++] + if (isProviderEnabled(provider)) { + val remainingEnabledProviders = 1 + request.providers + .drop(request.nextProviderIndex) + .count(::isProviderEnabled) + nextAttempt = ProviderAttempt( + provider = provider, + timeoutMs = maxOf(1L, remainingMs / remainingEnabledProviders) + ) } - - synchronized(activeOneShotListeners) { - activeOneShotListeners[callback] = listener - activeOneShotRunnables[callback] = timeoutRunnable - } - - locationManager.requestSingleUpdate(provider, listener, null) - handler.postDelayed(timeoutRunnable, 30000L) // 30s timeout } - providerFound = true - break + if (nextAttempt == null) { + shouldFinish = true + } + nextAttempt + } + } + } + + when { + attempt != null -> requestOneShotFromProvider(request, attempt) + shouldFinish -> finishOneShot(request, null) + } + } + + @SuppressLint("MissingPermission") + private fun requestOneShotFromProvider( + request: PendingOneShot, + attempt: ProviderAttempt + ) { + Log.d(TAG, "Requesting fresh location from ${attempt.provider}") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + requestCurrentLocation(request, attempt) + } else { + requestSingleLocationUpdate(request, attempt) + } + } + + @SuppressLint("MissingPermission") + private fun requestCurrentLocation( + request: PendingOneShot, + attempt: ProviderAttempt + ) { + val cancellationSignal = CancellationSignal() + val timeout = Runnable { + val resources = detachCurrentProvider( + request = request, + expectedCancellationSignal = cancellationSignal + ) ?: return@Runnable + Log.w(TAG, "Location request timed out for ${attempt.provider}") + releaseOneShotResources(resources) + startNextOneShotProvider(request) + } + + if (!attachCurrentProvider(request, cancellationSignal, null, timeout)) return + handler.postDelayed(timeout, attempt.timeoutMs) + + try { + locationManager.getCurrentLocation( + attempt.provider, + cancellationSignal, + context.mainExecutor + ) { location -> + val resources = detachCurrentProvider( + request = request, + expectedCancellationSignal = cancellationSignal + ) ?: return@getCurrentLocation + releaseOneShotResources(resources) + + if (location != null && hasLocationPermission()) { + finishOneShot(request, location) + } else { + startNextOneShotProvider(request) + } + } + } catch (e: Exception) { + val resources = detachCurrentProvider( + request = request, + expectedCancellationSignal = cancellationSignal + ) + if (resources != null) { + Log.w(TAG, "Unable to request ${attempt.provider} location", e) + releaseOneShotResources(resources) + startNextOneShotProvider(request) + } + } + } + + @SuppressLint("MissingPermission") + private fun requestSingleLocationUpdate( + request: PendingOneShot, + attempt: ProviderAttempt + ) { + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + val resources = detachCurrentProvider( + request = request, + expectedListener = this + ) ?: return + releaseOneShotResources(resources) + + if (hasLocationPermission()) { + finishOneShot(request, location) + } else { + startNextOneShotProvider(request) } } - if (!providerFound) { - Log.w(TAG, "No location providers available for fresh location") - callback(null) + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit + override fun onProviderEnabled(provider: String) = Unit + override fun onProviderDisabled(provider: String) = Unit + } + val timeout = Runnable { + val resources = detachCurrentProvider( + request = request, + expectedListener = listener + ) ?: return@Runnable + Log.w(TAG, "Location request timed out for ${attempt.provider}") + releaseOneShotResources(resources) + startNextOneShotProvider(request) + } + + if (!attachCurrentProvider(request, null, listener, timeout)) return + handler.postDelayed(timeout, attempt.timeoutMs) + + try { + locationManager.requestSingleUpdate(attempt.provider, listener, null) + if (!isCurrentProviderRequest(request, expectedListener = listener)) { + runCatching { locationManager.removeUpdates(listener) } } } catch (e: Exception) { - Log.e(TAG, "Error requesting fresh location") - callback(null) + val resources = detachCurrentProvider( + request = request, + expectedListener = listener + ) + if (resources != null) { + Log.w(TAG, "Unable to request ${attempt.provider} location", e) + releaseOneShotResources(resources) + startNextOneShotProvider(request) + } + } + } + + private fun isProviderEnabled(provider: String): Boolean { + return try { + locationManager.isProviderEnabled(provider) + } catch (e: Exception) { + Log.w(TAG, "Unable to inspect $provider provider", e) + false + } + } + + private fun attachCurrentProvider( + request: PendingOneShot, + cancellationSignal: CancellationSignal?, + listener: LocationListener?, + timeoutRunnable: Runnable + ): Boolean = synchronized(activeOneShotRequests) { + if (!isOneShotActiveLocked(request) || !hasLocationPermission()) { + false + } else { + request.cancellationSignal = cancellationSignal + request.listener = listener + request.timeoutRunnable = timeoutRunnable + true + } + } + + private fun isCurrentProviderRequest( + request: PendingOneShot, + expectedCancellationSignal: CancellationSignal? = null, + expectedListener: LocationListener? = null + ): Boolean = synchronized(activeOneShotRequests) { + if (!isOneShotActiveLocked(request)) { + false + } else { + (expectedCancellationSignal == null || request.cancellationSignal === expectedCancellationSignal) && + (expectedListener == null || request.listener === expectedListener) + } + } + + private fun detachCurrentProvider( + request: PendingOneShot, + expectedCancellationSignal: CancellationSignal? = null, + expectedListener: LocationListener? = null + ): OneShotResources? = synchronized(activeOneShotRequests) { + if (!isCurrentProviderRequestLocked(request, expectedCancellationSignal, expectedListener)) { + null + } else { + OneShotResources( + cancellationSignal = request.cancellationSignal, + listener = request.listener, + timeoutRunnable = request.timeoutRunnable + ).also { + request.cancellationSignal = null + request.listener = null + request.timeoutRunnable = null + } + } + } + + private fun isOneShotActiveLocked(request: PendingOneShot): Boolean = + !request.completed && activeOneShotRequests.contains(request) + + private fun isCurrentProviderRequestLocked( + request: PendingOneShot, + expectedCancellationSignal: CancellationSignal?, + expectedListener: LocationListener? + ): Boolean = + isOneShotActiveLocked(request) && + (expectedCancellationSignal == null || request.cancellationSignal === expectedCancellationSignal) && + (expectedListener == null || request.listener === expectedListener) + + private fun finishOneShot(request: PendingOneShot, location: Location?) { + val resources = synchronized(activeOneShotRequests) { + if (!isOneShotActiveLocked(request)) { + null + } else { + request.completed = true + activeOneShotRequests.remove(request) + OneShotResources( + cancellationSignal = request.cancellationSignal, + listener = request.listener, + timeoutRunnable = request.timeoutRunnable + ).also { + request.cancellationSignal = null + request.listener = null + request.timeoutRunnable = null + } + } + } ?: return + + releaseOneShotResources(resources) + request.callback(location.takeIf { hasLocationPermission() }) + } + + private fun releaseOneShotResources(resources: OneShotResources) { + resources.timeoutRunnable?.let(handler::removeCallbacks) + resources.cancellationSignal?.cancel() + resources.listener?.let { listener -> + runCatching { locationManager.removeUpdates(listener) } + .onFailure { Log.w(TAG, "Unable to remove one-shot location listener", it) } } } @@ -172,26 +374,25 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr ) { if (!hasLocationPermission()) return - try { - val listener = object : LocationListener { - override fun onLocationChanged(location: Location) { - if (LiveLocationPrivacyGate.isEnabled) callback(location) - } - override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} - override fun onProviderEnabled(provider: String) {} - override fun onProviderDisabled(provider: String) {} + removeLocationUpdates(callback) + + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + if (hasLocationPermission()) callback(location) } - // Store the listener so we can remove it later - synchronized(activeListeners) { - activeListeners[callback] = listener - } + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit + override fun onProviderEnabled(provider: String) = Unit + override fun onProviderDisabled(provider: String) = Unit + } - val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) - var registered = false - - for (provider in providers) { - if (locationManager.isProviderEnabled(provider)) { + var registered = false + var shouldCleanUp = false + synchronized(activeListeners) { + activeListeners[callback] = listener + for (provider in listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)) { + if (!isProviderEnabled(provider)) continue + try { locationManager.requestLocationUpdates( provider, intervalMs, @@ -200,62 +401,57 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr ) registered = true Log.d(TAG, "Registered updates for $provider") + } catch (e: Exception) { + Log.w(TAG, "Unable to register updates for $provider", e) } } - - if (!registered) { - Log.w(TAG, "No providers enabled for continuous updates") - } - } catch (e: Exception) { - Log.e(TAG, "Error requesting location updates") + shouldCleanUp = !registered || activeListeners[callback] !== listener + if (shouldCleanUp && activeListeners[callback] === listener) { + activeListeners.remove(callback) + } + } + + if (shouldCleanUp) { + runCatching { locationManager.removeUpdates(listener) } + Log.w(TAG, "No system providers accepted continuous location updates") } } override fun removeLocationUpdates(callback: (Location) -> Unit) { - try { - val listener = synchronized(activeListeners) { - activeListeners.remove(callback) - } - - if (listener != null) { - locationManager.removeUpdates(listener) - Log.d(TAG, "Removed location updates") - } - } catch (e: Exception) { - Log.e(TAG, "Error removing updates") + val listener = synchronized(activeListeners) { + activeListeners.remove(callback) + } + + if (listener != null) { + runCatching { locationManager.removeUpdates(listener) } + .onFailure { Log.w(TAG, "Unable to remove location updates", it) } } } override fun cancel() { - try { - // Cancel continuous updates - synchronized(activeListeners) { - for ((_, listener) in activeListeners) { - try { locationManager.removeUpdates(listener) } catch (_: Exception) {} - } - activeListeners.clear() - } - - // Cancel one-shot requests - synchronized(activeOneShotListeners) { - for ((_, listener) in activeOneShotListeners) { - try { locationManager.removeUpdates(listener) } catch (_: Exception) {} - } - activeOneShotListeners.clear() - - for ((_, runnable) in activeOneShotRunnables) { - handler.removeCallbacks(runnable) - } - 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") + val listeners = synchronized(activeListeners) { + activeListeners.values.toList().also { activeListeners.clear() } } + listeners.forEach { listener -> + runCatching { locationManager.removeUpdates(listener) } + .onFailure { Log.w(TAG, "Unable to remove location updates", it) } + } + + val oneShotResources = synchronized(activeOneShotRequests) { + activeOneShotRequests.map { request -> + request.completed = true + OneShotResources( + cancellationSignal = request.cancellationSignal, + listener = request.listener, + timeoutRunnable = request.timeoutRunnable + ) + }.also { + activeOneShotRequests.clear() + } + } + oneShotResources.forEach(::releaseOneShotResources) + Log.d(TAG, "Cancelled all system location requests") } } + From 046cde47ee992ac4391a0916119f7d2008749db7 Mon Sep 17 00:00:00 2001 From: liuyunming521520-cell Date: Wed, 9 Sep 2026 21:09:10 +0800 Subject: [PATCH 3/9] Guide location notes recovery --- .../android/ui/LocationNotesSheetPresenter.kt | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index 9a8cf9e7..8313eb1f 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -1,5 +1,10 @@ package com.bitchat.android.ui +import android.Manifest +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -33,6 +38,7 @@ fun LocationNotesSheetPresenter( val locationManager = remember { LocationChannelManager.getInstance(context) } val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle() val isLoadingLocation by locationManager.isLoadingLocation.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() @@ -57,7 +63,8 @@ fun LocationNotesSheetPresenter( // No building geohash available - show error state (matches iOS) LocationNotesErrorSheet( onDismiss = onDismiss, - locationManager = locationManager + locationManager = locationManager, + systemLocationEnabled = systemLocationEnabled ) } } @@ -106,8 +113,19 @@ private fun LocationNotesAcquiringSheet( @Composable private fun LocationNotesErrorSheet( onDismiss: () -> Unit, - locationManager: LocationChannelManager + locationManager: LocationChannelManager, + systemLocationEnabled: Boolean ) { + val context = LocalContext.current + val locationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { + locationManager.syncPermissionState() + locationManager.enableLocationServices() + locationManager.enableLocationChannels() + locationManager.refreshChannels() + } + BitchatBottomSheet( onDismissRequest = onDismiss, ) { @@ -127,19 +145,34 @@ private fun LocationNotesErrorSheet( ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Location permission is required for notes", + text = stringResource(R.string.location_notes_location_unavailable), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(24.dp)) Button(onClick = { - // UNIFIED FIX: Enable location services first (user toggle) - locationManager.enableLocationServices() - // Then request location channels (which will also request permission if needed) - locationManager.enableLocationChannels() - locationManager.refreshChannels() + when { + !systemLocationEnabled -> { + runCatching { + context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + } + } + locationManager.syncPermissionState() != LocationChannelManager.PermissionState.AUTHORIZED -> { + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + else -> { + locationManager.enableLocationServices() + locationManager.enableLocationChannels() + locationManager.refreshChannels() + } + } }) { - Text("Enable Location") + Text(stringResource(R.string.enable_location_services)) } } @@ -155,3 +188,4 @@ private fun LocationNotesErrorSheet( } } } + From fabf930ef786019419e1484c28d0d6df22b3c2ba Mon Sep 17 00:00:00 2001 From: liuyunming521520-cell Date: Wed, 9 Sep 2026 21:09:12 +0800 Subject: [PATCH 4/9] Add location failure guidance --- app/src/main/res/values/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a46d596f..6f6e0d60 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -442,6 +442,7 @@ Geo relays unavailable; notes paused No geo relays nearby Notes rely on geo relays. Check connection and try again. + Unable to determine your current location. Check device location and try again. Loading notes… No notes yet Be the first to add one for this spot. @@ -655,3 +656,4 @@ System default Select language + From 546c298efd7608c515f2e2193c96ae0f7c8541bf Mon Sep 17 00:00:00 2001 From: liuyunming521520-cell Date: Thu, 10 Sep 2026 23:56:08 +0800 Subject: [PATCH 5/9] Guard cached location fallback cancellation --- .../android/geohash/FusedLocationProvider.kt | 74 +++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt index 76febb60..5eec6f66 100644 --- a/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/FusedLocationProvider.kt @@ -23,12 +23,17 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro private val activeCallbacks = mutableMapOf<(Location) -> Unit, UpdateRegistration>() private val activeOneShotRequests = mutableSetOf() + private val activeLastKnownRequests = mutableSetOf() private class PendingOneShot { val cancellation = CancellationTokenSource() var fallbackStarted = false } + private class PendingLastKnown { + var fallbackStarted = false + } + private class UpdateRegistration( val fusedCallback: LocationCallback, val systemCallback: (Location) -> Unit @@ -50,22 +55,76 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro return } + val pending = PendingLastKnown() + synchronized(activeLastKnownRequests) { + activeLastKnownRequests += pending + } + try { fusedLocationClient.lastLocation .addOnSuccessListener { location -> if (location != null && hasLocationPermission()) { - callback(location) + completeLastKnown(pending, callback, location) } else { - systemFallback.getLastKnownLocation(callback) + startSystemLastKnownFallback(pending, callback) } } .addOnFailureListener { Log.e(TAG, "Error getting last-known fused location") - systemFallback.getLastKnownLocation(callback) + startSystemLastKnownFallback(pending, callback) } } catch (e: Exception) { Log.e(TAG, "Exception getting last-known fused location", e) - systemFallback.getLastKnownLocation(callback) + startSystemLastKnownFallback(pending, callback) + } + } + + private fun startSystemLastKnownFallback( + pending: PendingLastKnown, + callback: (Location?) -> Unit + ) { + var deliverNull = false + var shouldStartFallback = false + + synchronized(activeLastKnownRequests) { + when { + !activeLastKnownRequests.contains(pending) -> return + !hasLocationPermission() -> { + activeLastKnownRequests.remove(pending) + deliverNull = true + } + !pending.fallbackStarted -> { + pending.fallbackStarted = true + shouldStartFallback = true + } + } + } + + when { + deliverNull -> callback(null) + shouldStartFallback -> { + try { + systemFallback.getLastKnownLocation { location -> + completeLastKnown(pending, callback, location) + } + } catch (e: Exception) { + Log.w(TAG, "System last-known location fallback failed", e) + completeLastKnown(pending, callback, null) + } + } + } + } + + private fun completeLastKnown( + pending: PendingLastKnown, + callback: (Location?) -> Unit, + location: Location? + ) { + val shouldDeliver = synchronized(activeLastKnownRequests) { + activeLastKnownRequests.remove(pending) + } + if (shouldDeliver) { + callback(location.takeIf { hasLocationPermission() }) } } @@ -282,9 +341,12 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro synchronized(activeOneShotRequests) { activeOneShotRequests.forEach { it.cancellation.cancel() } activeOneShotRequests.clear() - // This instance owns the fallback provider, so it is safe to cancel all of its work here. - systemFallback.cancel() } + synchronized(activeLastKnownRequests) { + activeLastKnownRequests.clear() + } + // This instance owns the fallback provider, so it is safe to cancel all of its work here. + systemFallback.cancel() Log.d(TAG, "Cancelled all fused location requests") } } From 61c9e94e46b24f60b7c6d0314105e96ddbf32ce7 Mon Sep 17 00:00:00 2001 From: liuyunming521520-cell Date: Thu, 10 Sep 2026 23:56:10 +0800 Subject: [PATCH 6/9] Ignore stale system location callbacks --- .../com/bitchat/android/geohash/SystemLocationProvider.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt index 4c63c698..cfb0848e 100644 --- a/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt +++ b/app/src/main/java/com/bitchat/android/geohash/SystemLocationProvider.kt @@ -376,9 +376,13 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr removeLocationUpdates(callback) - val listener = object : LocationListener { + lateinit var listener: LocationListener + listener = object : LocationListener { override fun onLocationChanged(location: Location) { - if (hasLocationPermission()) callback(location) + val isCurrentListener = synchronized(activeListeners) { + activeListeners[callback] === listener + } + if (isCurrentListener && hasLocationPermission()) callback(location) } override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit From e7115d689900c7f84ad008a6e8cbeabdc5b8f8da Mon Sep 17 00:00:00 2001 From: liuyunming521520-cell Date: Thu, 10 Sep 2026 23:56:13 +0800 Subject: [PATCH 7/9] Respect denied location permission results --- .../android/ui/LocationNotesSheetPresenter.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index 8313eb1f..e99374c3 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -119,11 +119,17 @@ private fun LocationNotesErrorSheet( val context = LocalContext.current val locationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() - ) { - locationManager.syncPermissionState() - locationManager.enableLocationServices() - locationManager.enableLocationChannels() - locationManager.refreshChannels() + ) { permissionResults -> + val locationGranted = + permissionResults[Manifest.permission.ACCESS_FINE_LOCATION] == true || + permissionResults[Manifest.permission.ACCESS_COARSE_LOCATION] == true + val permissionState = locationManager.syncPermissionState() + + if (locationGranted && permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { + locationManager.enableLocationServices() + locationManager.enableLocationChannels() + locationManager.refreshChannels() + } } BitchatBottomSheet( From 37d698ad0f4317ac3be1aa0b2778d65f1acffc65 Mon Sep 17 00:00:00 2001 From: liuyunming521520-cell Date: Fri, 11 Sep 2026 00:01:28 +0800 Subject: [PATCH 8/9] Complete location notes recovery actions --- .../android/ui/LocationNotesSheetPresenter.kt | 72 +++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index e99374c3..3d174302 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -1,21 +1,30 @@ package com.bitchat.android.ui import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import android.content.Intent +import android.net.Uri import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.core.app.ActivityCompat import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle @@ -117,6 +126,8 @@ private fun LocationNotesErrorSheet( systemLocationEnabled: Boolean ) { val context = LocalContext.current + var locationPermissionRequestAttempted by rememberSaveable { mutableStateOf(false) } + var awaitingSystemLocationSettings by rememberSaveable { mutableStateOf(false) } val locationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { permissionResults -> @@ -132,6 +143,20 @@ private fun LocationNotesErrorSheet( } } + LifecycleResumeEffect(awaitingSystemLocationSettings, systemLocationEnabled) { + if (awaitingSystemLocationSettings && + systemLocationEnabled && + locationManager.syncPermissionState() == LocationChannelManager.PermissionState.AUTHORIZED + ) { + awaitingSystemLocationSettings = false + locationManager.enableLocationServices() + locationManager.enableLocationChannels() + locationManager.refreshChannels() + } + + onPauseOrDispose {} + } + BitchatBottomSheet( onDismissRequest = onDismiss, ) { @@ -159,17 +184,24 @@ private fun LocationNotesErrorSheet( Button(onClick = { when { !systemLocationEnabled -> { - runCatching { + awaitingSystemLocationSettings = runCatching { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) - } + }.isSuccess } locationManager.syncPermissionState() != LocationChannelManager.PermissionState.AUTHORIZED -> { - locationPermissionLauncher.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION + if (locationPermissionRequestAttempted && + hasPermanentlyDeniedLocationPermission(context) + ) { + openAppLocationSettings(context) + } else { + locationPermissionRequestAttempted = true + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) ) - ) + } } else -> { locationManager.enableLocationServices() @@ -195,3 +227,29 @@ private fun LocationNotesErrorSheet( } } +private fun hasPermanentlyDeniedLocationPermission(context: Context): Boolean { + val activity = context.findActivity() ?: return false + val locationPermissions = listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + return locationPermissions.all { permission -> + ActivityCompat.checkSelfPermission(context, permission) != android.content.pm.PackageManager.PERMISSION_GRANTED && + !ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + } +} + +private fun openAppLocationSettings(context: Context) { + runCatching { + context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + }) + } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + From d2e345842083fc61526fa903112c59006399ab68 Mon Sep 17 00:00:00 2001 From: liuyunming521520-cell Date: Fri, 11 Sep 2026 00:03:41 +0800 Subject: [PATCH 9/9] Refresh notes after settings recovery --- .../android/ui/LocationNotesSheetPresenter.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index 3d174302..3f88b4f0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -127,7 +127,7 @@ private fun LocationNotesErrorSheet( ) { val context = LocalContext.current var locationPermissionRequestAttempted by rememberSaveable { mutableStateOf(false) } - var awaitingSystemLocationSettings by rememberSaveable { mutableStateOf(false) } + var awaitingLocationSettingsRecovery by rememberSaveable { mutableStateOf(false) } val locationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { permissionResults -> @@ -143,12 +143,12 @@ private fun LocationNotesErrorSheet( } } - LifecycleResumeEffect(awaitingSystemLocationSettings, systemLocationEnabled) { - if (awaitingSystemLocationSettings && + LifecycleResumeEffect(awaitingLocationSettingsRecovery, systemLocationEnabled) { + if (awaitingLocationSettingsRecovery && systemLocationEnabled && locationManager.syncPermissionState() == LocationChannelManager.PermissionState.AUTHORIZED ) { - awaitingSystemLocationSettings = false + awaitingLocationSettingsRecovery = false locationManager.enableLocationServices() locationManager.enableLocationChannels() locationManager.refreshChannels() @@ -184,7 +184,7 @@ private fun LocationNotesErrorSheet( Button(onClick = { when { !systemLocationEnabled -> { - awaitingSystemLocationSettings = runCatching { + awaitingLocationSettingsRecovery = runCatching { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }.isSuccess } @@ -192,7 +192,7 @@ private fun LocationNotesErrorSheet( if (locationPermissionRequestAttempted && hasPermanentlyDeniedLocationPermission(context) ) { - openAppLocationSettings(context) + awaitingLocationSettingsRecovery = openAppLocationSettings(context) } else { locationPermissionRequestAttempted = true locationPermissionLauncher.launch( @@ -239,12 +239,12 @@ private fun hasPermanentlyDeniedLocationPermission(context: Context): Boolean { } } -private fun openAppLocationSettings(context: Context) { - runCatching { +private fun openAppLocationSettings(context: Context): Boolean { + return runCatching { context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.fromParts("package", context.packageName, null) }) - } + }.isSuccess } private tailrec fun Context.findActivity(): Activity? = when (this) {