From 3478411d7ee6239d920a35571dfb131bdc9ea9df Mon Sep 17 00:00:00 2001 From: Christoph <47949835+shtrophic@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:48:42 +0100 Subject: [PATCH] OSM: search by tags "amenity" and "shop" after delocalizing (#1253) * OSM: search by tags "amenity" and "shop" after delocalizing * use overpassQL UNION * document Union usage * cleanup unused imports * refine restaurant queries * two more :) * Hardcode string -> tag assignment * Remove log statement --------- Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com> --- .../main/java/de/mm20/launcher2/ktx/String.kt | 8 +- .../openstreetmaps/OsmLocationProvider.kt | 197 +++++++++++++----- .../providers/openstreetmaps/OverpassApi.kt | 46 ++-- 3 files changed, 181 insertions(+), 70 deletions(-) diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/String.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/String.kt index f003659a..016a128e 100644 --- a/core/ktx/src/main/java/de/mm20/launcher2/ktx/String.kt +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/String.kt @@ -29,4 +29,10 @@ fun String.normalize(): String { */ fun String.romanize(): String { return Pinyin.toPinyin(this, "") -} \ No newline at end of file +} + +fun String.stripStartOrNull(s: String): String? + = if (startsWith(s)) removePrefix(s) else null + +fun String.stripEndOrNull(s: String): String? + = if (endsWith(s)) removeSuffix(s) else null \ No newline at end of file diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt index 609768e4..fbb2bc47 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt @@ -5,16 +5,16 @@ import android.util.Log import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.locations.providers.AndroidLocation import de.mm20.launcher2.locations.providers.LocationProvider +import de.mm20.launcher2.openstreetmaps.R import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.search.Location +import de.mm20.launcher2.search.ResultScore import de.mm20.launcher2.search.UpdateResult import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -38,7 +38,8 @@ internal class OsmLocationProvider( try { Retrofit.Builder() .client(HttpClient) - .baseUrl(it?.takeIf { it.isNotBlank() } ?: LocationSearchSettings.DefaultOverpassUrl) + .baseUrl(it?.takeIf { it.isNotBlank() } + ?: LocationSearchSettings.DefaultOverpassUrl) .addConverterFactory(OverpassQueryConverterFactory()) .addConverterFactory(GsonConverterFactory.create()) .build() @@ -109,62 +110,144 @@ internal class OsmLocationProvider( HttpClient.dispatcher.cancelAll() } - suspend fun searchByTag(tag: String): OverpassResponse? = - overpassApi.first()?.runCatching { - this.search( - OverpassFuzzyRadiusQuery( - tag = tag, - query = query, - radius = searchRadiusMeters, - latitude = userLocation.latitude, - longitude = userLocation.longitude, - ) + return overpassApi.first()?.runCatching { + search( + OverpassFuzzyRadiusQuery( + query = query, + tagGroups = delocalizeToQueryableTags(query), + radius = searchRadiusMeters, + latitude = userLocation.latitude, + longitude = userLocation.longitude ) - }?.onFailure { - if (it !is HttpException && it !is CancellationException) { - Log.e("OsmLocationProvider", "Failed to search for $tag: $query", it) - } - }?.getOrNull() - - val result = awaitAll( - // optionally query by "amenity" or "shop" here - // if we want to make searching for locations fuzzier - // however, this would not account for localized queries like "Bäcker" (shop:bakery) - Scope.async { searchByTag("name") }, - Scope.async { searchByTag("brand") }, - ).flatMap { - it?.let { - OsmLocation.fromOverpassResponse(it, context) - } ?: emptyList() - } - - return result - .asSequence() - .filter { - (!hideUncategorized || (it.category != null)) && it.distanceTo(userLocation) < searchRadiusMeters + ) + }?.onFailure { + if (it !is HttpException && it !is CancellationException) { + Log.e("OsmLocationProvider", "Failed to search for: $query", it) } - .groupBy { - it.label.lowercase() + }?.getOrNull()?.let { + OsmLocation.fromOverpassResponse(it, context) + }?.asSequence()?.filter { + (!hideUncategorized || (it.category != null)) && it.distanceTo(userLocation) < searchRadiusMeters + }?.groupBy { + it.label.lowercase() + }?.flatMap { (_, duplicates) -> + // deduplicate results with same labels, if + // - same category + // - distance is less than 100m + if (duplicates.size < 2) duplicates + else { + val luckyFirst = duplicates.first() + duplicates + .drop(1) + .filter { + it.category != luckyFirst.category || + it.distanceTo(luckyFirst) > 100.0 + } + luckyFirst } - .flatMap { (_, duplicates) -> - // deduplicate results with same labels, if - // - same category - // - distance is less than 100m - if (duplicates.size < 2) duplicates - else { - val luckyFirst = duplicates.first() - duplicates - .drop(1) - .filter { - it.category != luckyFirst.category || - it.distanceTo(luckyFirst) > 100.0 - } + luckyFirst - } - } - .sortedBy { - it.distanceTo(userLocation) - } - .take(7) - .toImmutableList() + }?.sortedBy { + it.distanceTo(userLocation) + }?.take(9)?.toImmutableList() ?: emptyList() } + + private val poiCategories = mapOf( + R.string.poi_category_restaurant to "amenity=restaurant", + R.string.poi_category_fast_food to "amenity=fast_food", + R.string.poi_category_bar to "amenity=bar", + R.string.poi_category_cafe to "amenity=cafe", + R.string.poi_category_hotel to "tourism=hotel", + R.string.poi_category_supermarket to "shop=supermarket", + R.string.poi_category_school to "amenity=school", + R.string.poi_category_parking to "amenity=parking", + R.string.poi_category_fuel to "amenity=fuel", + R.string.poi_category_toilets to "amenity=toilets", + R.string.poi_category_pharmacy to "amenity=pharmacy", + R.string.poi_category_hospital to "amenity=hospital", + R.string.poi_category_post_office to "amenity=post_office", + R.string.poi_category_pub to "amenity=pub", + R.string.poi_category_doctors to "amenity=doctors", + R.string.poi_category_police to "amenity=police", + R.string.poi_category_dentist to "amenity=dentist", + R.string.poi_category_library to "amenity=library", + R.string.poi_category_ice_cream to "amenity=ice_cream", + R.string.poi_category_theater to "amenity=theatre", + R.string.poi_category_cinema to "amenity=cinema", + R.string.poi_category_nightclub to "amenity=nightclub", + R.string.poi_category_clinic to "amenity=clinic", + R.string.poi_category_university to "amenity=university", + R.string.poi_category_clothes to "shop=clothes", + R.string.poi_category_convenience to "shop=convenience", + R.string.poi_category_hairdresser to "shop=hairdresser", + R.string.poi_category_books to "shop=books", + R.string.poi_category_bakery to "shop=bakery", + R.string.poi_category_car_rental to "amenity=car_rental", + R.string.poi_category_car_sharing to "amenity=car_sharing", + R.string.poi_category_mobile_phone to "shop=mobile_phone", + R.string.poi_category_furniture to "shop=furniture", + R.string.poi_category_alcohol to "shop=alcohol", + R.string.poi_category_florist to "shop=florist", + R.string.poi_category_mall to "shop=mall", + R.string.poi_category_optician to "shop=optician", + R.string.poi_category_jewelry to "shop=jewelry", + R.string.poi_category_laundry to "amenity=laundry", + R.string.poi_category_bank to "amenity=bank", + R.string.poi_category_soccer to "leisure=pitch,sport=soccer", + R.string.poi_category_basketball to "leisure=pitch,sport=basketball", + R.string.poi_category_tennis to "leisure=pitch,sport=tennis", + R.string.poi_category_atm to "amenity=atm", + R.string.poi_category_kiosk to "shop=kiosk", + R.string.poi_category_museum to "tourism=museum", + R.string.poi_category_fitness_center to "leisure=fitness_centre", + R.string.poi_category_church to "amenity=place_of_worship,religion=christian", + R.string.poi_category_mosque to "amenity=place_of_worship,religion=muslim", + R.string.poi_category_buddhist_temple to "amenity=place_of_worship,religion=buddhist", + R.string.poi_category_hindu_temple to "amenity=place_of_worship,religion=hindu", + R.string.poi_category_synagogue to "amenity=place_of_worship,religion=jewish", + R.string.poi_category_pizza_restaurant to "amenity=restaurant,cuisine=pizza", + R.string.poi_category_burger_restaurant to "amenity=restaurant,cuisine=burger", + R.string.poi_category_place_of_worship to "amenity=place_of_worship", + R.string.poi_category_chinese_restaurant to "amenity=restaurant,cuisine=chinese", + R.string.poi_category_japanese_restaurant to "amenity=restaurant,cuisine=japanese", + R.string.poi_category_kebab_restaurant to "amenity=restaurant,cuisine=kebab", + R.string.poi_category_asian_restaurant to "amenity=restaurant,cuisine=asian", + R.string.poi_category_ramen_restaurant to "amenity=restaurant,cuisine=ramen", + R.string.poi_category_soup_restaurant to "amenity=restaurant,cuisine=soup", + R.string.poi_category_brunch_restaurant to "amenity=restaurant,cuisine=brunch", + R.string.poi_category_car_wash to "amenity=car_wash", + R.string.poi_category_charging_station to "amenity=charging_station", + R.string.poi_category_motorcycle_rental to "amenity=motorcycle_rental", + R.string.poi_category_gallery to "tourism=gallery", + R.string.poi_category_amusement_park to "tourism=theme_park", + R.string.poi_category_concert_hall to "amenity=concert_hall", + R.string.poi_category_stadium to "leisure=stadium", + R.string.poi_category_casino to "amenity=casino", + R.string.poi_category_discount_store to "shop=discount", + R.string.poi_category_pet to "shop=pet", + R.string.poi_category_shopping to "shop=mall", + R.string.poi_category_swimming to "leisure=swimming_pool", + R.string.poi_category_martial_arts to "leisure=sports_centre,sport=martial_arts", + R.string.poi_category_golf to "leisure=golf_course", + R.string.poi_category_gymnastics to "leisure=sports_hall,sport=gymnastics", + R.string.poi_category_ice_hockey to "leisure=sports_centre,sport=ice_hockey", + R.string.poi_category_baseball to "leisure=pitch,sport=baseball", + R.string.poi_category_american_football to "leisure=pitch,sport=american_football", + R.string.poi_category_handball to "leisure=pitch,sport=handball", + R.string.poi_category_volleyball to "leisure=pitch,sport=volleyball", + R.string.poi_category_skiing to "leisure=piste", + R.string.poi_category_cricket to "leisure=pitch,sport=cricket", + R.string.poi_category_park to "leisure=park", + R.string.poi_category_monument to "historic=monument", + R.string.poi_category_government_building to "building=government", + R.string.poi_category_fire_station to "amenity=fire_station", + R.string.poi_category_courthouse to "amenity=courthouse", + R.string.poi_category_townhall to "amenity=townhall" + ).mapKeys { context.getString(it.key) } + + private fun delocalizeToQueryableTags(localizedQuery: String): List = + poiCategories.mapNotNull { (string, tags) -> + val score = ResultScore( + localizedQuery, + primaryFields = listOf(string) + ) + if (score.score > 0.8f) tags else null + } } diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OverpassApi.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OverpassApi.kt index d00b66f4..4fe44c72 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OverpassApi.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OverpassApi.kt @@ -9,13 +9,28 @@ import retrofit2.http.POST import java.lang.reflect.Type import kotlin.math.cos +/** + * Overpass API query builder + * Searches for nodes and ways that at least: + * - match the query string in their name or brand tag + * - match one of the given tag groups + */ data class OverpassFuzzyRadiusQuery( - val tag: String = "name", + /** + * Free text query to search for. + */ val query: String, + /** + * Tags groups to search for. Each item represents a group of tags, separated by commas. + * The query matches if all tags in a group are present in the element. + * For example: + * ["amenity=restaurant,cuisine=italian", "amenity=cafe"] + * This query will match elements that are either a restaurant with italian cuisine or a cafe. + */ + val tagGroups: List, val radius: Int, val latitude: Double, val longitude: Double, - val caseInvariant: Boolean = true, ) data class OverpassIdQuery( @@ -50,12 +65,7 @@ interface OverpassApi { class OverpassFuzzyRadiusQueryConverter : Converter { override fun convert(value: OverpassFuzzyRadiusQuery): RequestBody { - - // allow other characters in between query words, if there are multiple - // https://dev.overpass-api.de/overpass-doc/en/criteria/per_tag.html#regex - val escapedQueryName = value - .query - .split(' ') + val encodedQuery = value.query.split(' ') .joinToString( separator = ".*", prefix = "\"", @@ -65,11 +75,23 @@ class OverpassFuzzyRadiusQueryConverter : Converter