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..5eec6f66 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,32 @@ 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 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 + ) { + 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 ) @@ -37,18 +55,76 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro return } + val pending = PendingLastKnown() + synchronized(activeLastKnownRequests) { + activeLastKnownRequests += pending + } + try { fusedLocationClient.lastLocation .addOnSuccessListener { location -> - callback(location.takeIf { LiveLocationPrivacyGate.isEnabled }) + if (location != null && hasLocationPermission()) { + completeLastKnown(pending, callback, location) + } else { + startSystemLastKnownFallback(pending, callback) + } } - .addOnFailureListener { e -> + .addOnFailureListener { Log.e(TAG, "Error getting last-known fused location") - callback(null) + startSystemLastKnownFallback(pending, 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) + 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() }) } } @@ -59,33 +135,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 +222,132 @@ 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() + } + 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") } } + 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..cfb0848e 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,29 @@ 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) + removeLocationUpdates(callback) + + lateinit var listener: LocationListener + listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + val isCurrentListener = synchronized(activeListeners) { + activeListeners[callback] === listener } - override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} - override fun onProviderEnabled(provider: String) {} - override fun onProviderDisabled(provider: String) {} + if (isCurrentListener && 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 +405,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") } } + 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..3f88b4f0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -1,16 +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 @@ -33,6 +47,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 +72,8 @@ fun LocationNotesSheetPresenter( // No building geohash available - show error state (matches iOS) LocationNotesErrorSheet( onDismiss = onDismiss, - locationManager = locationManager + locationManager = locationManager, + systemLocationEnabled = systemLocationEnabled ) } } @@ -106,8 +122,41 @@ private fun LocationNotesAcquiringSheet( @Composable private fun LocationNotesErrorSheet( onDismiss: () -> Unit, - locationManager: LocationChannelManager + locationManager: LocationChannelManager, + systemLocationEnabled: Boolean ) { + val context = LocalContext.current + var locationPermissionRequestAttempted by rememberSaveable { mutableStateOf(false) } + var awaitingLocationSettingsRecovery by rememberSaveable { mutableStateOf(false) } + val locationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { 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() + } + } + + LifecycleResumeEffect(awaitingLocationSettingsRecovery, systemLocationEnabled) { + if (awaitingLocationSettingsRecovery && + systemLocationEnabled && + locationManager.syncPermissionState() == LocationChannelManager.PermissionState.AUTHORIZED + ) { + awaitingLocationSettingsRecovery = false + locationManager.enableLocationServices() + locationManager.enableLocationChannels() + locationManager.refreshChannels() + } + + onPauseOrDispose {} + } + BitchatBottomSheet( onDismissRequest = onDismiss, ) { @@ -127,19 +176,41 @@ 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 -> { + awaitingLocationSettingsRecovery = runCatching { + context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + }.isSuccess + } + locationManager.syncPermissionState() != LocationChannelManager.PermissionState.AUTHORIZED -> { + if (locationPermissionRequestAttempted && + hasPermanentlyDeniedLocationPermission(context) + ) { + awaitingLocationSettingsRecovery = openAppLocationSettings(context) + } else { + locationPermissionRequestAttempted = true + 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 +226,30 @@ 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): 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) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + 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 +