fix(location): stop spinning Around Me when Geocoder is unavailable

On AOSP / GrapheneOS without Google Play Services (or microG), Android's
system Geocoder has no backing IGeocodeProvider, so `Geocoder.isPresent()`
returns false. Two compounding bugs left the Around Me top-bar spinner
running forever in that case:

1. `CachedReversedGeoLocations.geoLocate` only invoked `onReady` when
   Geocoder was present AND returned a non-null, non-blank city name. In
   the not-present and empty-result paths the callback never fired.
2. `LoadCityName` looped while `notReady`, but `notReady` was only flipped
   off when `newCityName != cityName`. With both null the loop never
   terminated, doubling its delay each pass.

Now `geoLocate` always calls `onReady` exactly once, short-circuiting
with `null` when no Geocoder backend exists. `LoadCityName` bridges the
callback to a suspending call, caps retries at 5, and falls back to
rendering the raw geohash so the spinner always stops. The "no geocoder
backend" check short-circuits the whole retry path on devices that will
never be able to resolve a name.
This commit is contained in:
Claude
2026-05-19 21:32:11 +00:00
parent d42482ff56
commit 6c4ff97cf1
2 changed files with 67 additions and 45 deletions
@@ -32,6 +32,14 @@ import com.vitorpamplona.quartz.utils.Log
object CachedReversedGeoLocations {
val locationNames = LruCache<String, String>(20)
// Geocoder.isPresent() reflects whether the system has a backing
// IGeocodeProvider registered — fixed at boot, safe to memoize. On AOSP /
// GrapheneOS without Google Play Services or microG configured no provider
// is registered and this returns false.
private val geocoderAvailable: Boolean by lazy { Geocoder.isPresent() }
fun isGeocoderAvailable(): Boolean = geocoderAvailable
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
fun geoLocate(
@@ -42,31 +50,24 @@ object CachedReversedGeoLocations {
) {
locationNames[geoHashStr]?.let {
onReady(it)
return
}
if (Geocoder.isPresent()) {
ReverseGeolocation.execute(location, context) { cityNames ->
if (cityNames != null) {
val cityName =
cityNames.firstNotNullOfOrNull {
val name = it.toCityCountry()
if (!name.isBlank()) {
name
} else {
null
}
}
if (cityName != null) {
locationNames.put(geoHashStr, cityName)
onReady(cityName)
}
} else {
// error
onReady(null)
if (!geocoderAvailable) {
Log.d("ReverseGeoLocation") { "Geocoder service not present on this device" }
onReady(null)
return
}
ReverseGeolocation.execute(location, context) { cityNames ->
val cityName =
cityNames?.firstNotNullOfOrNull {
it.toCityCountry().takeIf { name -> name.isNotBlank() }
}
if (cityName != null) {
locationNames.put(geoHashStr, cityName)
}
} else {
Log.d("ReverseGeoLocation", "Geocoder not present")
onReady(cityName)
}
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.note.creators.location
import android.content.Context
import android.location.Location
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -30,7 +32,10 @@ import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
import com.vitorpamplona.amethyst.service.location.toGeoHash
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
private const val MAX_GEOLOCATE_RETRIES = 5
@Composable
fun LoadCityName(
@@ -40,34 +45,50 @@ fun LoadCityName(
) {
var cityName by remember(geohashStr) { mutableStateOf(CachedReversedGeoLocations.cached(geohashStr)) }
if (cityName == null) {
if (onLoading != null) {
onLoading()
}
val resolved = cityName
if (resolved != null) {
content(resolved)
} else if (!CachedReversedGeoLocations.isGeocoderAvailable()) {
// Devices without a system Geocoder backend (e.g. AOSP / GrapheneOS
// without Google Play Services or microG) cannot reverse-geocode at
// all. Show the geohash directly instead of spinning forever.
content(geohashStr)
} else {
onLoading?.invoke()
val context = LocalContext.current
LaunchedEffect(key1 = geohashStr, context) {
LaunchedEffect(geohashStr, context) {
val location = runCatching { geohashStr.toGeoHash() }.getOrNull()?.toLocation()
if (location != null) {
launch {
var notReady = true
var myStep = 1000L
while (notReady) {
// Retries while the Reverse Geolocation service is offline.
CachedReversedGeoLocations.geoLocate(geohashStr, location, context) { newCityName ->
if (newCityName != cityName) {
notReady = false
cityName = newCityName
}
}
myStep = myStep * 2L
delay(myStep)
}
}
if (location == null) {
cityName = geohashStr
return@LaunchedEffect
}
var delayMs = 1000L
repeat(MAX_GEOLOCATE_RETRIES) {
val result = geoLocateOnce(geohashStr, location, context)
if (result != null) {
cityName = result
return@LaunchedEffect
}
delay(delayMs)
delayMs *= 2L
}
// Geocoder kept returning errors — give up and render the geohash
// so the loading indicator stops.
cityName = geohashStr
}
} else {
cityName?.let { content(it) }
}
}
private suspend fun geoLocateOnce(
geohashStr: String,
location: Location,
context: Context,
): String? =
suspendCancellableCoroutine { cont ->
CachedReversedGeoLocations.geoLocate(geohashStr, location, context) { result ->
if (cont.isActive) cont.resume(result)
}
}