From 6c4ff97cf100fb8a2da176b239570bb667764e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 21:32:11 +0000 Subject: [PATCH] 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. --- .../location/CachedReversedGeoLocations.kt | 43 ++++++------ .../ui/note/creators/location/LoadCityName.kt | 69 ++++++++++++------- 2 files changed, 67 insertions(+), 45 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/CachedReversedGeoLocations.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/CachedReversedGeoLocations.kt index 75fdd005d5..572ba999c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/CachedReversedGeoLocations.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/CachedReversedGeoLocations.kt @@ -32,6 +32,14 @@ import com.vitorpamplona.quartz.utils.Log object CachedReversedGeoLocations { val locationNames = LruCache(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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt index 4420368b0b..05cce5516b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LoadCityName.kt @@ -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) + } + }