Merge d2e345842083fc61526fa903112c59006399ab68 into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
liuyunming521520-cell 2026-09-13 21:59:42 +08:00 committed by GitHub
commit 295bc7965c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 721 additions and 230 deletions

View File

@ -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<CancellationTokenSource>()
private val fusedLocationClient: FusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(context)
private val systemFallback = SystemLocationProvider(context)
private val activeCallbacks = mutableMapOf<(Location) -> Unit, UpdateRegistration>()
private val activeOneShotRequests = mutableSetOf<PendingOneShot>()
private val activeLastKnownRequests = mutableSetOf<PendingLastKnown>()
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")
}
}

View File

@ -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<PendingOneShot>()
private class PendingOneShot(
val callback: (Location?) -> Unit,
val providers: List<String>,
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")
}
}

View File

@ -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
}

View File

@ -442,6 +442,7 @@
<string name="location_notes_relays_unavailable">Geo relays unavailable; notes paused</string>
<string name="location_notes_no_relays_title">No geo relays nearby</string>
<string name="location_notes_no_relays_desc">Notes rely on geo relays. Check connection and try again.</string>
<string name="location_notes_location_unavailable">Unable to determine your current location. Check device location and try again.</string>
<string name="loading_location_notes">Loading notes…</string>
<string name="location_notes_empty_title">No notes yet</string>
<string name="location_notes_empty_desc">Be the first to add one for this spot.</string>
@ -655,3 +656,4 @@
<string name="about_system_default">System default</string>
<string name="about_select_language">Select language</string>
</resources>