- Retries to reverse geolocate if fails due to the services long startup time.

- Adds support for the new and old ways to use reverse geolocation services.
- Hits all existing providers for location updates instead of just the network ones
- Uses past last known locations to kick start the cache.
- Improves logging to check potential failures in production.
This commit is contained in:
Vitor Pamplona
2025-03-29 11:00:37 -04:00
parent 76eee177a8
commit 5ac73bbeba
11 changed files with 388 additions and 209 deletions
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import android.location.Address
fun Address.toCityCountry(): String =
buildString {
var includeComma = false
val city = locality ?: subAdminArea
if (city != null && city.isNotBlank()) {
append(city)
includeComma = true
}
if (countryCode != null && countryCode.isNotBlank()) {
if (includeComma) append(", ")
append(countryCode)
}
}
@@ -0,0 +1,72 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.util.Log
import android.util.LruCache
/**
* Maintains a cache of geohashes and city,country pairs.
*/
object CachedReversedGeoLocations {
val locationNames = LruCache<String, String>(20)
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
fun geoLocate(
geoHashStr: String,
location: Location,
context: Context,
onReady: (String?) -> Unit,
) {
locationNames[geoHashStr]?.let {
onReady(it)
}
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)
}
}
} else {
Log.d("ReverseGeoLocation", "Geocoder not present")
}
}
}
@@ -0,0 +1,78 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Looper
import android.util.Log
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
class LocationFlow(
private val context: Context,
) {
@SuppressLint("MissingPermission")
fun get(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
): Flow<Location> =
callbackFlow {
Log.i("LocationFlow", "Start")
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
object : LocationListener {
override fun onLocationChanged(location: Location) {
Log.d("LocationFlow", "onLocationChanged $location")
launch { send(location) }
}
}
locationManager.allProviders.forEach {
val location = locationManager.getLastKnownLocation(it)
Log.d("LocationFlow", "Last Known location is $location")
if (location != null) {
send(location)
}
Log.d("LocationFlow", "Requesting Updates")
locationManager.requestLocationUpdates(
it,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
}
awaitClose {
Log.i("LocationFlow", "Stop")
locationManager.removeUpdates(locationCallback)
}
}
}
@@ -20,74 +20,21 @@
*/
package com.vitorpamplona.amethyst.service.location
import android.annotation.SuppressLint
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Looper
import android.util.Log
import android.util.LruCache
import coil3.util.CoilUtils.result
import com.fonfon.kgeohash.GeoHash
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
class LocationFlow(
private val context: Context,
) {
@SuppressLint("MissingPermission")
fun get(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
): Flow<Location> =
callbackFlow {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
object : LocationListener {
override fun onLocationChanged(location: Location) {
launch { send(location) }
}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
Log.d("Location Service", "LocationState Start")
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
awaitClose {
locationManager.removeUpdates(locationCallback)
Log.d("Location Service", "LocationState Stop")
}
}
}
class LocationState(
context: Context,
@@ -121,120 +68,28 @@ class LocationState(
val geohashStateFlow =
hasLocationPermission
.transformLatest {
emitAll(
LocationFlow
(context)
.get(MIN_TIME, MIN_DISTANCE)
.map {
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
}.onEach {
latestLocation = it
}.catch { e ->
e.printStackTrace()
latestLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
},
)
if (it) {
emit(LocationResult.Loading)
val result =
LocationFlow(context)
.get(MIN_TIME, MIN_DISTANCE)
.map {
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
}.onEach {
latestLocation = it
}.catch { e ->
e.printStackTrace()
latestLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
}
emitAll(result)
} else {
emit(LocationResult.LackPermission)
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
}
object CachedGeoLocations {
val locationNames = LruCache<String, String>(20)
fun cached(geoHashStr: String): String? = locationNames[geoHashStr]
suspend fun geoLocate(
geoHashStr: String,
location: Location,
context: Context,
): String? {
locationNames[geoHashStr]?.let {
return it
}
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
if (name != null) {
locationNames.put(geoHashStr, name)
}
return name
}
}
private class ReverseGeoLocationUtil {
suspend fun execute(
location: Location,
context: Context,
): String? {
return try {
Geocoder(context)
.getFromLocation(
location.latitude,
location.longitude,
1,
)?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
return null
}
}
}
class ReverseGeoLocationFlow(
private val context: Context,
) {
@SuppressLint("MissingPermission")
fun get(location: Location): Flow<String?> =
callbackFlow {
val locationManager = Geocoder(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val locationCallback =
(
Geocoder.GeocodeListener { addresses ->
launch {
send(
addresses.firstOrNull()?.let {
listOfNotNull(it.locality ?: it.subAdminArea, it.countryCode).joinToString(", ")
},
)
}
}
)
Log.d("GeoLocation Service", "LocationState Start")
locationManager
.getFromLocation(
location.latitude,
location.longitude,
1,
locationCallback,
)
} else {
launch {
send(
Geocoder(context)
.getFromLocation(
location.latitude,
location.longitude,
1,
)?.firstOrNull()
?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode)
.joinToString(", ")
},
)
}
}
}
}
@@ -0,0 +1,92 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import android.content.Context
import android.location.Address
import android.location.Geocoder
import android.location.Location
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import java.io.IOException
class ReverseGeolocation {
companion object {
fun execute(
location: Location,
context: Context,
onReady: (List<Address>?) -> Unit,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
executeAsync(location, context, onReady)
} else {
onReady(executeSync(location, context))
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun executeAsync(
location: Location,
context: Context,
onReady: (List<Address>?) -> Unit,
) {
val locationCallback =
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: List<Address>) {
Log.d("ReverseGeoLocation", "Found ${addresses.size} new addresses")
onReady(addresses)
}
override fun onError(errorMessage: String?) {
super.onError(errorMessage)
Log.w("ReverseGeoLocation", "Failure $errorMessage")
onReady(null)
}
}
Log.d("ReverseGeoLocation", "Execute Async $location")
Geocoder(context).getFromLocation(
location.latitude,
location.longitude,
1,
locationCallback,
)
}
fun executeSync(
location: Location,
context: Context,
): List<Address>? {
Log.d("ReverseGeoLocation", "Execute Sync $location")
return try {
Geocoder(context).getFromLocation(
location.latitude,
location.longitude,
1,
)
} catch (e: IOException) {
e.printStackTrace()
return null
}
}
}
}
@@ -45,6 +45,7 @@ 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.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.map
@@ -56,7 +57,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition
import com.vitorpamplona.amethyst.ui.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -138,8 +139,15 @@ fun FeedFilterSpinner(
LoadCityName(
geohashStr = myLocation.geoHash.toString(),
onLoading = {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
Row {
Text(
text = "(${myLocation.geoHash})",
fontSize = 12.sp,
lineHeight = 12.sp,
)
Spacer(modifier = StdHorzSpacer)
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
}
},
) { cityName ->
Text(
@@ -27,14 +27,11 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.location.CachedGeoLocations
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
@@ -191,41 +188,6 @@ fun LoadOts(
}
}
@Composable
fun LoadCityName(
geohashStr: String,
onLoading: (@Composable () -> Unit)? = null,
content: @Composable (String) -> Unit,
) {
var cityName by remember(geohashStr) { mutableStateOf(CachedGeoLocations.cached(geohashStr)) }
if (cityName == null) {
if (onLoading != null) {
onLoading()
}
val context = LocalContext.current
LaunchedEffect(key1 = geohashStr, context) {
launch(Dispatchers.IO) {
val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull()
if (geoHash != null) {
val newCityName =
CachedGeoLocations
.geoLocate(geohashStr, geoHash.toLocation(), context)
?.ifBlank { null }
if (newCityName != null && newCityName != cityName) {
cityName = newCityName
}
}
}
}
} else {
cityName?.let { content(it) }
}
}
@Composable
fun LoadChannel(
baseChannelHex: String,
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@@ -32,7 +33,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -0,0 +1,73 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.creators.location
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun LoadCityName(
geohashStr: String,
onLoading: (@Composable () -> Unit)? = null,
content: @Composable (String) -> Unit,
) {
var cityName by remember(geohashStr) { mutableStateOf(CachedReversedGeoLocations.cached(geohashStr)) }
if (cityName == null) {
if (onLoading != null) {
onLoading()
}
val context = LocalContext.current
LaunchedEffect(key1 = 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)
}
}
}
}
} else {
cityName?.let { content(it) }
}
}
@@ -27,7 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.theme.Font14SP
@Composable
@@ -45,7 +45,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.NostrGeoHashFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel