Improve reverse geocoding of countries

This commit is contained in:
Julien Papasian 2025-08-14 18:40:31 +02:00
parent f1e51b6219
commit 6e284e505b
10 changed files with 255 additions and 144 deletions

View file

@ -178,6 +178,8 @@ class RefreshHelper @Inject constructor(
* - Apply updated coordinates
* - Reverse geocoding (if current location)
* On non-current location, just returns the location
*
* TODO: Remove redundancy with default reverse geocoding calls
*/
suspend fun getLocation(
context: Context,
@ -189,6 +191,8 @@ class RefreshHelper @Inject constructor(
return LocationResult(location, emptyList())
}
var needsCountryCodeRefresh = false
val currentErrors = mutableListOf<RefreshError>()
// STEP 1 - Update coordinates if current position
@ -271,7 +275,8 @@ class RefreshHelper @Inject constructor(
)
location
} else {
locationRepository.update(it)
needsCountryCodeRefresh = !location.reverseGeocodingSource.isNullOrEmpty() &&
!location.reverseGeocodingSource.equals(BuildConfig.DEFAULT_GEOCODING_SOURCE)
it
}
}
@ -302,9 +307,7 @@ class RefreshHelper @Inject constructor(
).copy(
// We failed to refresh, so retry reverse geocoding next time
needsGeocodeRefresh = true
).also {
locationRepository.update(it)
}
)
} catch (_: Throwable) {
/**
* Returns the original location
@ -330,13 +333,25 @@ class RefreshHelper @Inject constructor(
locationWithUpdatedCoordinates // Same as "location"
}
// STEP 3 - Add timezone if missing
// STEP 3 - Validate ambiguous ISO 3166 codes
val locationInfoFromDefaultSource = if (needsCountryCodeRefresh) {
getLocationWithUnambiguousCountryCode(locationGeocoded, context)
} else {
locationGeocoded
}
// STEP 4 - Add timezone if missing
val locationWithTimeZone = if (locationGeocoded.isTimeZoneInvalid) {
locationGeocoded.copy(
locationInfoFromDefaultSource.copy(
timeZone = getTimeZoneForLocation(context, locationGeocoded)
)
} else {
locationGeocoded
locationInfoFromDefaultSource
}
// STEP 5 - If there was any change, update in database
if (location != locationWithTimeZone) {
locationRepository.update(locationWithTimeZone)
}
return LocationResult(locationWithTimeZone, currentErrors)
@ -377,6 +392,35 @@ class RefreshHelper @Inject constructor(
}
}
suspend fun getLocationWithUnambiguousCountryCode(
location: Location,
context: Context,
): Location {
return if (ambigousCountryCodes.any { cc -> location.countryCode.equals(cc, ignoreCase = true) }) {
try {
// Getting the address for this from the fallback reverse geocoding source
requestReverseGeocoding(
sourceManager.getReverseGeocodingSourceOrDefault(BuildConfig.DEFAULT_GEOCODING_SOURCE),
location,
context
).let {
if (!it.countryCode.equals(location.countryCode, ignoreCase = true)) {
location.copy(
countryCode = it.countryCode,
country = it.country
)
} else {
location
}
}
} catch (_: Throwable) {
location
}
} else {
location
}
}
suspend fun updateLocation(location: Location, oldFormattedId: String? = null) {
locationRepository.update(location, oldFormattedId)
}
@ -1327,5 +1371,28 @@ class RefreshHelper @Inject constructor(
const val CACHING_DISTANCE_LIMIT = 5000 // 5 km
const val REVERSE_GEOCODING_DISTANCE_LIMIT = 50000 // 50 km
/**
* For technical reasons, we need to better identify each territory
* Crimea is not included to let each location search/address lookup source resolves it the way they want
* and we will resolve the timezone as Europe/Simferopol whether identified as UA or RU
*/
private val ambigousCountryCodes = arrayOf(
"AR", // Claims: AQ
"AU", // Territories: CX, CC, HM (uninhabited), NF. Claims: AQ
"CL", // Claims: AQ
"CN", // Territories: HK, MO. Claims: TW
"DK", // Territories: FO, GL
"FI", // Territories: AX
"FR", // Territories: GF, PF, TF (uninhabited), GP, MQ, YT, NC, RE, BL, MF, PM, WF. Claims: AQ
"GB", // Territories: AI, BM, IO, KY, FK, GI, GG, IM, JE, MS, PN, SH, GS (uninhabited), TC, VG. Claims: AQ
"IL", // Claims: PS
"MA", // Claims: EH
"NL", // Territories: AW, BQ, CW, SX
"NO", // Territories: BV, SJ. Claims: AQ
"NZ", // Territories: TK. Associated states: CK, NU. Claims: AQ
"RS", // Claims: XK
"US" // Territories: AS, GU, MP, PR, UM (uninhabited), VI
)
}
}

View file

@ -21,16 +21,18 @@ import breezyweather.domain.location.model.LocationAddressInfo
import breezyweather.domain.source.SourceFeature
import com.google.maps.android.PolyUtil
import com.google.maps.android.SphericalUtil
import com.google.maps.android.data.geojson.GeoJsonFeature
import com.google.maps.android.data.Feature
import com.google.maps.android.data.geojson.GeoJsonMultiPolygon
import com.google.maps.android.data.geojson.GeoJsonParser
import com.google.maps.android.data.geojson.GeoJsonPoint
import com.google.maps.android.data.geojson.GeoJsonPolygon
import com.google.maps.android.model.LatLng
import dagger.hilt.android.qualifiers.ApplicationContext
import io.reactivex.rxjava3.core.Observable
import org.breezyweather.R
import org.breezyweather.common.extensions.codeForNaturalEarth
import org.breezyweather.common.extensions.currentLocale
import org.breezyweather.common.extensions.getCountryName
import org.breezyweather.common.source.ReverseGeocodingSource
import org.breezyweather.common.utils.helpers.LogHelper
import org.breezyweather.sources.RefreshHelper
@ -50,13 +52,21 @@ import javax.inject.Inject
*
* https://mapshaper.org/ was used to convert to GeoJSON
* TODO: It would be best to make our own converter so that we can exclude features we dont want and
* make the geojson file more lightweight
* make the geojson file more lightweight
*/
class NaturalEarthService @Inject constructor() : ReverseGeocodingSource {
class NaturalEarthService @Inject constructor(
@ApplicationContext context: Context,
) : ReverseGeocodingSource {
override val id = "naturalearth"
override val name = "Natural Earth"
private val geoJsonParser: GeoJsonParser by lazy {
val text = context.resources.openRawResource(R.raw.ne_50m_admin_0_countries)
.bufferedReader().use { it.readText() }
GeoJsonParser(JSONObject(text))
}
override val supportedFeatures = mapOf(
SourceFeature.REVERSE_GEOCODING to name
)
@ -68,13 +78,7 @@ class NaturalEarthService @Inject constructor() : ReverseGeocodingSource {
): Observable<List<LocationAddressInfo>> {
val languageCode = context.currentLocale.codeForNaturalEarth
// Countries
val matchingCountries = getMatchingFeaturesForLocation(
context,
R.raw.ne_50m_admin_0_countries,
latitude,
longitude
)
val matchingCountries = geoJsonParser.features.filter { isMatchingFeature(it, latitude, longitude) }
if (matchingCountries.size != 1) {
LogHelper.log(
msg = "[NaturalEarthService] Reverse geocoding skipped: ${matchingCountries.size} matching results"
@ -82,44 +86,39 @@ class NaturalEarthService @Inject constructor() : ReverseGeocodingSource {
return Observable.just(emptyList())
}
val countryCode = matchingCountries[0].getProperty("ISO_A2") ?: ""
return Observable.just(
listOf(
LocationAddressInfo(
country = matchingCountries[0].getProperty("NAME_$languageCode")
country = countryCode.takeIf { it.isNotEmpty() }
?.let { context.currentLocale.getCountryName(it) }
?: matchingCountries[0].getProperty("NAME_$languageCode")
?: matchingCountries[0].getProperty("NAME_LONG"),
countryCode = matchingCountries[0].getProperty("ISO_A2")
?: ""
countryCode = countryCode
)
)
)
}
private fun getMatchingFeaturesForLocation(
context: Context,
file: Int,
private fun isMatchingFeature(
feature: Feature,
latitude: Double,
longitude: Double,
): List<GeoJsonFeature> {
val text = context.resources.openRawResource(file)
.bufferedReader().use { it.readText() }
val geoJsonParser = GeoJsonParser(JSONObject(text))
return geoJsonParser.features.filter { feature ->
when (feature.geometry) {
is GeoJsonPolygon -> (feature.geometry as GeoJsonPolygon).coordinates.any { polygon ->
): Boolean {
return when (feature.geometry) {
is GeoJsonPolygon -> (feature.geometry as GeoJsonPolygon).coordinates.any { polygon ->
PolyUtil.containsLocation(latitude, longitude, polygon, true)
}
is GeoJsonMultiPolygon -> (feature.geometry as GeoJsonMultiPolygon).polygons.any {
it.coordinates.any { polygon ->
PolyUtil.containsLocation(latitude, longitude, polygon, true)
}
is GeoJsonMultiPolygon -> (feature.geometry as GeoJsonMultiPolygon).polygons.any {
it.coordinates.any { polygon ->
PolyUtil.containsLocation(latitude, longitude, polygon, true)
}
}
is GeoJsonPoint -> SphericalUtil.computeDistanceBetween(
LatLng(latitude, longitude),
(feature.geometry as GeoJsonPoint).coordinates
) < RefreshHelper.REVERSE_GEOCODING_DISTANCE_LIMIT
else -> false
}
is GeoJsonPoint -> SphericalUtil.computeDistanceBetween(
LatLng(latitude, longitude),
(feature.geometry as GeoJsonPoint).coordinates
) < RefreshHelper.REVERSE_GEOCODING_DISTANCE_LIMIT
else -> false
}
}
}

View file

@ -155,7 +155,7 @@ class MainActivity : BreezyActivity(), HomeFragment.Callback, ManagementFragment
if (viewModel.locationExists(location)) {
SnackbarHelper.showSnackbar(getString(R.string.location_message_already_exists))
} else {
viewModel.addLocation(location, null)
viewModel.addLocation(location, null, this)
SnackbarHelper.showSnackbar(getString(R.string.location_message_added))
}
}

View file

@ -481,8 +481,7 @@ class MainActivityViewModel @Inject constructor(
fun addLocation(
location: Location,
index: Int? = null,
context: Context? = null, // Needed if adding timezone
addTimeZoneIfMissing: Boolean = false,
context: Context? = null, // Needed for timezone
): Boolean {
// do not add an existing location.
if (validLocationList.value.firstOrNull { it.formattedId == location.formattedId } != null) {
@ -491,7 +490,7 @@ class MainActivityViewModel @Inject constructor(
_locationListLoading.value = true
val locationToAdd = if (addTimeZoneIfMissing && context != null && location.isTimeZoneInvalid) {
val locationWithValidTimeZone = if (context != null && location.isTimeZoneInvalid) {
location.copy(
timeZone = runBlocking {
refreshHelper.getTimeZoneForLocation(context, location)
@ -502,7 +501,7 @@ class MainActivityViewModel @Inject constructor(
}
val valid = validLocationList.value.toMutableList()
valid.add(index ?: valid.size, locationToAdd)
valid.add(index ?: valid.size, locationWithValidTimeZone)
updateInnerData(valid)
writeLocationList(locationList = valid)

View file

@ -472,7 +472,7 @@ open class ManagementFragment : MainModuleFragment(), TouchReactor {
if (viewModel.locationExists(newLocation)) {
SnackbarHelper.showSnackbar(getString(R.string.location_message_already_exists))
} else {
viewModel.addLocation(newLocation, null)
viewModel.addLocation(newLocation, null, requireContext())
SnackbarHelper.showSnackbar(getString(R.string.location_message_added))
}
}
@ -495,7 +495,7 @@ open class ManagementFragment : MainModuleFragment(), TouchReactor {
if (viewModel.locationExists(addedLocation)) {
SnackbarHelper.showSnackbar(getString(R.string.location_message_already_exists))
} else {
viewModel.addLocation(addedLocation, null)
viewModel.addLocation(addedLocation, null, requireContext())
SnackbarHelper.showSnackbar(getString(R.string.location_message_added))
}
}

View file

@ -66,19 +66,15 @@ import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.lifecycle.ViewModelProvider
import breezyweather.domain.location.model.Location
import breezyweather.domain.source.SourceFeature
import dagger.hilt.android.AndroidEntryPoint
import org.breezyweather.BuildConfig
import org.breezyweather.R
import org.breezyweather.common.basic.BreezyActivity
import org.breezyweather.common.extensions.inputMethodManager
import org.breezyweather.domain.location.model.applyDefaultPreset
import org.breezyweather.domain.location.model.getPlace
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.sources.SourceManager
import org.breezyweather.sources.getConfiguredLocationSearchSources
import org.breezyweather.sources.getLocationSearchSourceOrDefault
import org.breezyweather.sources.getWeatherSource
import org.breezyweather.ui.common.composables.AlertDialogNoPadding
import org.breezyweather.ui.common.composables.SecondarySourcesPreference
import org.breezyweather.ui.common.widgets.Material3Scaffold
@ -110,8 +106,9 @@ class SearchActivity : BreezyActivity() {
private fun ContentView() {
val context = LocalContext.current
val dialogLocationSearchSourceOpenState = rememberSaveable { mutableStateOf(false) }
val dialogLocationSourcesOpenState = rememberSaveable { mutableStateOf(false) }
var selectedLocation: Location? by rememberSaveable { mutableStateOf(null) }
val dialogLocationSourcesOpenState = viewModel.dialogLocationSourcesOpenState.collectAsState()
val selectedLocation = viewModel.selectedLocation.collectAsState()
val isLoading = viewModel.isLoading.collectAsState()
var text by rememberSaveable { mutableStateOf("") }
var latestTextSearch by rememberSaveable { mutableStateOf("") }
val locationSearchSourceState = viewModel.locationSearchSource.collectAsState()
@ -184,7 +181,7 @@ class SearchActivity : BreezyActivity() {
)
HorizontalDivider(color = SearchBarDefaults.colors().dividerColor)
val listResourceState = viewModel.listResource.collectAsState()
if (listResourceState.value.second == LoadableLocationStatus.LOADING) {
if (listResourceState.value.second == LoadableLocationStatus.LOADING || isLoading.value) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (listResourceState.value.first.isNotEmpty()) {
@ -199,101 +196,20 @@ class SearchActivity : BreezyActivity() {
headlineContent = { Text(location.getPlace(context)) },
supportingContent = { Text(location.administrationLevels()) },
modifier = Modifier.clickable {
val defaultSource = SettingsManager.getInstance(context).defaultForecastSource
selectedLocation = when (defaultSource) {
"auto" -> location.applyDefaultPreset(sourceManager)
else -> {
val source = sourceManager.getWeatherSource(defaultSource)
if (source == null) {
location.applyDefaultPreset(sourceManager)
} else {
location.copy(
forecastSource = source.id,
currentSource = if (SourceFeature.CURRENT in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.CURRENT
)
) {
source.id
} else {
null
},
airQualitySource = if (SourceFeature.AIR_QUALITY in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.AIR_QUALITY
)
) {
source.id
} else {
null
},
pollenSource = if (SourceFeature.POLLEN in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.POLLEN
)
) {
source.id
} else {
null
},
minutelySource = if (SourceFeature.MINUTELY in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.MINUTELY
)
) {
source.id
} else {
null
},
alertSource = if (SourceFeature.ALERT in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.ALERT
)
) {
source.id
} else {
null
},
normalsSource = if (SourceFeature.NORMALS in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.NORMALS
)
) {
source.id
} else {
null
}
)
}
}
}
dialogLocationSourcesOpenState.value = true
viewModel.setSelectedLocation(location)
}
)
}
}
if (dialogLocationSourcesOpenState.value && selectedLocation != null) {
if (dialogLocationSourcesOpenState.value && selectedLocation.value != null) {
SecondarySourcesPreference(
sourceManager = sourceManager,
location = selectedLocation!!,
location = selectedLocation.value!!,
onClose = { location: Location? ->
if (location != null) {
finishSelf(location)
}
dialogLocationSourcesOpenState.value = false
viewModel.closeDialogLocationSources()
}
)
}

View file

@ -19,20 +19,26 @@ package org.breezyweather.ui.search
import android.content.Context
import breezyweather.domain.location.model.Location
import breezyweather.domain.location.model.LocationAddressInfo
import breezyweather.domain.source.SourceFeature
import dagger.hilt.android.qualifiers.ApplicationContext
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.observers.DisposableObserver
import org.breezyweather.BuildConfig
import org.breezyweather.common.rxjava.ObserverContainer
import org.breezyweather.common.rxjava.SchedulerTransformer
import org.breezyweather.domain.location.model.applyDefaultPreset
import org.breezyweather.domain.settings.ConfigStore
import org.breezyweather.domain.settings.SettingsManager
import org.breezyweather.sources.RefreshHelper
import org.breezyweather.sources.SourceManager
import org.breezyweather.sources.getWeatherSource
import org.breezyweather.ui.main.utils.RefreshErrorType
import javax.inject.Inject
class SearchActivityRepository @Inject internal constructor(
@ApplicationContext context: Context,
private val mRefreshHelper: RefreshHelper,
private val mSourceManager: SourceManager,
private val mCompositeDisposable: CompositeDisposable,
) {
private val mConfig: ConfigStore = ConfigStore(context, PREFERENCE_SEARCH_CONFIG)
@ -76,6 +82,100 @@ class SearchActivityRepository @Inject internal constructor(
)
}
suspend fun getLocationWithUnambiguousCountryCode(
location: Location,
context: Context,
): Location {
return mRefreshHelper.getLocationWithUnambiguousCountryCode(location, context)
}
fun getLocationWithAppliedPreference(
location: Location,
context: Context,
): Location {
val defaultSource = SettingsManager.getInstance(context).defaultForecastSource
return when (defaultSource) {
"auto" -> location.applyDefaultPreset(mSourceManager)
else -> {
val source = mSourceManager.getWeatherSource(defaultSource)
if (source == null) {
location.applyDefaultPreset(mSourceManager)
} else {
location.copy(
forecastSource = source.id,
currentSource = if (SourceFeature.CURRENT in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.CURRENT
)
) {
source.id
} else {
null
},
airQualitySource = if (SourceFeature.AIR_QUALITY in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.AIR_QUALITY
)
) {
source.id
} else {
null
},
pollenSource = if (SourceFeature.POLLEN in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.POLLEN
)
) {
source.id
} else {
null
},
minutelySource = if (SourceFeature.MINUTELY in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.MINUTELY
)
) {
source.id
} else {
null
},
alertSource = if (SourceFeature.ALERT in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.ALERT
)
) {
source.id
} else {
null
},
normalsSource = if (SourceFeature.NORMALS in
source.supportedFeatures &&
source.isFeatureSupportedForLocation(
location,
SourceFeature.NORMALS
)
) {
source.id
} else {
null
}
)
}
}
}
}
var lastSelectedLocationSearchSource: String
set(value) {
mConfig.edit().putString(KEY_LAST_DEFAULT_SOURCE, value).apply()

View file

@ -17,6 +17,7 @@
package org.breezyweather.ui.search
import android.app.Application
import androidx.lifecycle.viewModelScope
import breezyweather.domain.location.model.Location
import breezyweather.domain.location.model.LocationAddressInfo
import dagger.hilt.android.lifecycle.HiltViewModel
@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
import org.breezyweather.BreezyWeather
import org.breezyweather.common.basic.BreezyViewModel
import org.breezyweather.common.extensions.currentLocale
import org.breezyweather.common.extensions.launchIO
import org.breezyweather.common.utils.helpers.SnackbarHelper
import org.breezyweather.ui.main.utils.RefreshErrorType
import javax.inject.Inject
@ -42,6 +44,12 @@ class SearchViewModel @Inject constructor(
repository.lastSelectedLocationSearchSource
)
val locationSearchSource = _locationSearchSource.asStateFlow()
private val _selectedLocation: MutableStateFlow<Location?> = MutableStateFlow(null)
val selectedLocation = _selectedLocation.asStateFlow()
private val _dialogLocationSourcesOpenState: MutableStateFlow<Boolean> = MutableStateFlow(false)
val dialogLocationSourcesOpenState = _dialogLocationSourcesOpenState.asStateFlow()
private val _isLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private val mRepository: SearchActivityRepository = repository
fun requestLocationList(str: String) {
@ -95,6 +103,22 @@ class SearchViewModel @Inject constructor(
_locationSearchSource.value = locSearchSource
}
fun setSelectedLocation(location: Location) {
viewModelScope.launchIO {
_isLoading.value = true
_selectedLocation.value = mRepository.getLocationWithAppliedPreference(
mRepository.getLocationWithUnambiguousCountryCode(location, getApplication()),
getApplication()
)
_isLoading.value = false
_dialogLocationSourcesOpenState.value = true
}
}
fun closeDialogLocationSources() {
_dialogLocationSourcesOpenState.value = false
}
override fun onCleared() {
super.onCleared()
mRepository.cancel()

File diff suppressed because one or more lines are too long

View file

@ -20,7 +20,6 @@ const val COORDINATES_PRECISION = 5
* on the original Natural Earth file already converted to JSON
*
* TODO: Make this task convert shapefile to json instead of relying on external tools
* TODO: Add missing Penghu and Matsu islands to Taiwan geometry as they are supported by the Taiwanese CWA source
*/
fun TaskContainerScope.registerNaturalEarthConfigTask(project: Project): TaskProvider<Task> {
return with(project) {
@ -81,8 +80,15 @@ fun TaskContainerScope.registerNaturalEarthConfigTask(project: Project): TaskPro
"NAME_LEN"
)
properties.keys().forEach { k ->
if (!k.startsWith("NAME_") && k != "ISO_A2") {
propertiesToRemove.add(k)
if (k != "ISO_A2") {
if (!k.startsWith("NAME_")) {
// Remove everything we don't need
propertiesToRemove.add(k)
} else if (!properties.getString("ISO_A2").isNullOrEmpty()) {
// Remove every name as Android already provides it
// Unless it's an unknown country (with no ISO A2)
propertiesToRemove.add(k)
}
}
}
propertiesToRemove.forEach { p ->