diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index bb270b80..de85cf0b 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -73,5 +73,6 @@
+
\ No newline at end of file
diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml
index 84565e04..388e1800 100644
--- a/app/app/src/main/AndroidManifest.xml
+++ b/app/app/src/main/AndroidManifest.xml
@@ -39,6 +39,8 @@
android:fullBackupContent="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:largeHeap="true"
+ android:hardwareAccelerated="true"
android:requestLegacyExternalStorage="true"
android:resizeableActivity="true"
android:supportsRtl="true"
diff --git a/app/ui/src/main/AndroidManifest.xml b/app/ui/src/main/AndroidManifest.xml
index fb84288b..10160632 100644
--- a/app/ui/src/main/AndroidManifest.xml
+++ b/app/ui/src/main/AndroidManifest.xml
@@ -15,6 +15,7 @@
android:stateNotNeeded="true"
android:theme="@style/LauncherTheme"
android:enableOnBackInvokedCallback="true"
+ android:configChanges="keyboard|keyboardHidden|screenLayout|layoutDirection|navigation|orientation|uiMode"
android:windowSoftInputMode="stateHidden|adjustResize">
diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt
index b8715035..2409de37 100644
--- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt
+++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/LauncherActivity.kt
@@ -1,6 +1,8 @@
package de.mm20.launcher2.ui.launcher
import android.content.Intent
+import android.content.res.Configuration
+import android.util.Log
import com.android.launcher3.GestureNavContract
import de.mm20.launcher2.ui.launcher.scaffold.ScaffoldPage
@@ -19,6 +21,12 @@ class LauncherActivity: SharedLauncherActivity(LauncherActivityMode.Launcher) {
}
}
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ Log.e("LauncherActivity","newConfig.keyboard ${newConfig.keyboard}")
+ Log.e("LauncherActivity","newConfig.keyboardHidden ${newConfig.keyboardHidden}")
+ Log.e("LauncherActivity","newConfig.hardKeyboardHidden ${newConfig.hardKeyboardHidden}")
+ }
override fun onPause() {
super.onPause()
enterHomeTransitionManager.clear()
diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt
index fa8591e4..4f1f55f1 100644
--- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt
+++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt
@@ -250,10 +250,10 @@ class SearchVM : ViewModel(), KoinComponent {
previousResults = SearchResults(apps = apps)
searchActionResults.clear()
- appResults.mergeWith(apps)
- workAppResults.mergeWith(workApps)
- privateSpaceAppResults.mergeWith(privateApps)
- hiddenResults.mergeWith(hiddenItems)
+ appResults.updateItems(apps)
+ workAppResults.updateItems(workApps)
+ privateSpaceAppResults.updateItems(privateApps)
+ hiddenResults.updateItems(hiddenItems)
}
} else {
@@ -273,19 +273,31 @@ class SearchVM : ViewModel(), KoinComponent {
workAppResults.clear()
privateSpaceAppResults.clear()
- appResults.mergeWith(results.apps, hiddenKeys, query)
- appShortcutResults.mergeWith(results.shortcuts, hiddenKeys, query)
- fileResults.mergeWith(results.files, hiddenKeys, query)
+ appResults.updateItems(
+ results.apps
+ ?.filterNot { hiddenKeys.contains(it.key) }
+ ?.applyRanking(query)
+ )
+ appShortcutResults.updateItems(
+ results.shortcuts
+ ?.filterNot { hiddenKeys.contains(it.key) }
+ ?.applyRanking(query)
+ )
+ fileResults.updateItems(
+ results.files
+ ?.filterNot { hiddenKeys.contains(it.key) }
+ ?.applyRanking(query)
+ )
- contactResults.mergeWith(
+ contactResults.updateItems(
results.contacts?.filterNot { hiddenKeys.contains(it.key) }
?.applyRanking(query)
)
- calendarResults.mergeWith(
+ calendarResults.updateItems(
results.calendars?.filterNot { hiddenKeys.contains(it.key) }
?.applyRanking(query)
)
- locationResults.mergeWith(
+ locationResults.updateItems(
results.locations?.filterNot { hiddenKeys.contains(it.key) }
?.let { locations ->
devicePoseProvider.lastCachedLocation?.let {
@@ -298,17 +310,17 @@ class SearchVM : ViewModel(), KoinComponent {
} ?: locations.applyRanking(query)
}
)
- articleResults.mergeWith(
+ articleResults.updateItems(
results.wikipedia?.applyRanking(query)
)
- websiteResults.mergeWith(
+ websiteResults.updateItems(
results.websites?.applyRanking(query)
)
- calculatorResults.mergeWith(results.calculators)
- unitConverterResults.mergeWith(results.unitConverters)
+ calculatorResults.updateItems(results.calculators)
+ unitConverterResults.updateItems(results.unitConverters)
if (results.searchActions != null) {
- searchActionResults.mergeWith(results.searchActions!!)
+ searchActionResults.updateItems(results.searchActions!!)
}
if (launchOnEnter.value) {
@@ -429,24 +441,18 @@ class SearchVM : ViewModel(), KoinComponent {
return sorted.distinctBy { it.key }.toList()
}
- private fun SnapshotStateList.mergeWith(newItems: List?) {
- val items = newItems ?: emptyList()
- val diff = toSet() subtract items.toSet()
- removeAll(diff)
- for ((i, item) in items.withIndex()) {
- if (i < size)
- set(i, item)
- else
- add(item)
- }
+ /**
+ * Merges a list of new items into the current SnapshotStateList.
+ * It removes items that are in the current list but not in the new list.
+ * Then, it updates existing items or adds new items from the new list.
+ *
+ * @param T The type of items in the list.
+ * @param newItems The list of new items to merge with. If null, an empty list is used.
+ */
+ private fun SnapshotStateList.updateItems(newItems: List?) {
+ clear()
+ addAll(newItems ?: emptyList())
}
-
- private suspend fun SnapshotStateList.mergeWith(
- newItems: List?,
- hiddenKeys: List,
- query: String
- ) = this.mergeWith((newItems ?: emptyList()).filterNot { hiddenKeys.contains(it.key) }
- .applyRanking(query))
}
diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt
index 25366f7b..8b4af1c0 100644
--- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt
+++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt
@@ -226,11 +226,19 @@ fun LocationItem(
this@AnimatedContent
)
)
+
+// val rateing =
val formattedDistance =
distance?.metersToLocalizedString(context, imperialUnits)
- val sublabel = listOf(location.category, formattedDistance)
- .fastFilterNotNull()
- .joinToString(" • ")
+ val sublabel = if (location.category?.contains("|") == true) {
+ listOf(location.category!!.split("|")!!.first(), formattedDistance,location.userRating)
+ .fastFilterNotNull()
+ .joinToString(" • ")
+ } else {
+ listOf(location.category, formattedDistance,location.userRating)
+ .fastFilterNotNull()
+ .joinToString(" • ")
+ }
val isOpenString = location.openingSchedule?.isOpen()
?.let { stringResource(if (it) R.string.location_open else R.string.location_closed) }
diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt
index b08b3ba6..3f064a37 100644
--- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt
+++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/WidgetColumn.kt
@@ -66,7 +66,7 @@ fun WidgetColumn(
Column(
- modifier = modifier
+ modifier = modifier.fillMaxWidth()
) {
val scope = rememberCoroutineScope()
Column {
diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt
index a762e0a7..1255f84f 100644
--- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt
+++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/ClockWidget.kt
@@ -693,4 +693,4 @@ fun ConfigureClockWidgetSheet(
}
}
}
-}
+}
\ No newline at end of file
diff --git a/core/i18n/src/main/res/values-ko/strings.xml b/core/i18n/src/main/res/values-ko/strings.xml
index 7831fb1a..ed1a4772 100644
--- a/core/i18n/src/main/res/values-ko/strings.xml
+++ b/core/i18n/src/main/res/values-ko/strings.xml
@@ -442,7 +442,7 @@
서점
꽃집
교회
- 레스토랑
+ 레스토랑|음식점|식당|밥집|분식
패스트푸드
호텔
카페
diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt
index 73c31136..ba4b5e2e 100644
--- a/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt
+++ b/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt
@@ -249,7 +249,7 @@ internal class AppRepositoryImpl(
appResults.addAll(apps.mapNotNull {
val score = ResultScore(
query = query,
- primaryFields = listOf(it.label),
+ primaryFields = listOf(it.label,it.componentName.packageName),
)
if (score.score < 0.8f) return@mapNotNull null
it.copy(
diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/googleapi/OsmLocation.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/googleapi/OsmLocation.kt
new file mode 100644
index 00000000..d101af3a
--- /dev/null
+++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/googleapi/OsmLocation.kt
@@ -0,0 +1,579 @@
+package de.mm20.launcher2.locations.providers.googleapi
+
+import android.content.Context
+import de.mm20.launcher2.ktx.ifNullOrEmpty
+import de.mm20.launcher2.ktx.into
+import de.mm20.launcher2.ktx.map
+import de.mm20.launcher2.ktx.stripStartOrNull
+import de.mm20.launcher2.locations.OsmLocationSerializer
+import de.mm20.launcher2.openstreetmaps.R
+import de.mm20.launcher2.search.Location
+import de.mm20.launcher2.search.SavableSearchable
+import de.mm20.launcher2.search.SearchableSerializer
+import de.mm20.launcher2.search.UpdatableSearchable
+import de.mm20.launcher2.search.UpdateResult
+import de.mm20.launcher2.search.location.Address
+import de.mm20.launcher2.search.location.Departure
+import de.mm20.launcher2.search.location.LocationIcon
+import de.mm20.launcher2.search.location.OpeningHours
+import de.mm20.launcher2.search.location.OpeningSchedule
+import de.mm20.launcher2.search.location.PaymentMethod
+import de.westnordost.osm_opening_hours.model.ClockTime
+import de.westnordost.osm_opening_hours.model.ExtendedClockTime
+import de.westnordost.osm_opening_hours.model.LastNth
+import de.westnordost.osm_opening_hours.model.MonthRange
+import de.westnordost.osm_opening_hours.model.Nth
+import de.westnordost.osm_opening_hours.model.NthRange
+import de.westnordost.osm_opening_hours.model.Range
+import de.westnordost.osm_opening_hours.model.SingleMonth
+import de.westnordost.osm_opening_hours.model.SpecificWeekdays
+import de.westnordost.osm_opening_hours.model.StartingAtYear
+import de.westnordost.osm_opening_hours.model.TimeSpan
+import de.westnordost.osm_opening_hours.model.TimesSelector
+import de.westnordost.osm_opening_hours.model.TwentyFourSeven
+import de.westnordost.osm_opening_hours.model.Weekday
+import de.westnordost.osm_opening_hours.model.WeekdayRange
+import de.westnordost.osm_opening_hours.model.WeekdaysSelector
+import de.westnordost.osm_opening_hours.model.Year
+import de.westnordost.osm_opening_hours.model.YearRange
+import de.westnordost.osm_opening_hours.parser.toOpeningHoursOrNull
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import org.woheller69.AndroidAddressFormatter.OsmAddressFormatter
+import java.time.DayOfWeek
+import java.time.Duration
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.temporal.ChronoUnit
+import java.time.temporal.TemporalAdjusters
+import java.util.Locale
+import kotlin.math.min
+
+internal data class OsmLocation(
+ internal val id: Long,
+ override val label: String,
+ override val icon: LocationIcon?,
+ override val category: String?,
+ override val latitude: Double,
+ override val longitude: Double,
+ override val address: Address?,
+ override val openingSchedule: OpeningSchedule?,
+ override val websiteUrl: String?,
+ override val phoneNumber: String?,
+ override val emailAddress: String? = null,
+ override val labelOverride: String? = null,
+ override val timestamp: Long,
+ override var updatedSelf: (suspend (SavableSearchable) -> UpdateResult)? = null,
+ override val userRating: Float?,
+ override val acceptedPaymentMethods: Map?
+) : Location, UpdatableSearchable {
+
+ override val domain: String
+ get() = DOMAIN
+ override val key: String = "$domain://$id"
+ override val fixMeUrl: String
+ get() = FIXMEURL
+
+ override val userRatingCount: Int? = null
+ override val departures: List? = null
+
+ override fun overrideLabel(label: String): OsmLocation {
+ return this.copy(labelOverride = label)
+ }
+
+ override fun getSerializer(): SearchableSerializer {
+ return OsmLocationSerializer()
+ }
+
+ companion object {
+
+ internal const val DOMAIN = "osm"
+ internal const val FIXMEURL = "https://www.openstreetmap.org/fixthemap"
+
+ internal val addressFormatter =
+ OsmAddressFormatter(
+ false,
+ false,
+ false
+ )
+
+ fun fromOverpassResponse(
+ result: OverpassResponse,
+ context: Context
+ ): List = result.elements.mapNotNull {
+ it.tags ?: return@mapNotNull null
+ val (category, icon) = it.tags.categorize(context)
+ icon ?: return@mapNotNull null
+ OsmLocation(
+ id = it.id,
+ label = it.tags["name"] ?: it.tags["brand"] ?: return@mapNotNull null,
+ icon = icon,
+ category = category,
+ latitude = it.lat ?: it.center?.lat ?: return@mapNotNull null,
+ longitude = it.lon ?: it.center?.lon ?: return@mapNotNull null,
+ address = it.tags.toAddress(),
+ openingSchedule = it.tags["opening_hours"]?.let { ot -> parseOpeningSchedule(ot) },
+ websiteUrl = it.tags["website"] ?: it.tags["contact:website"],
+ phoneNumber = it.tags["phone"] ?: it.tags["contact:phone"],
+ emailAddress = it.tags["email"] ?: it.tags["contact:email"],
+ timestamp = System.currentTimeMillis(),
+ userRating = it.tags["stars"]?.runCatching { this.toInt() }?.getOrNull()
+ ?.let { min(it, 5) / 5.0f },
+ acceptedPaymentMethods = with(
+ it.tags.mapNotNull { (key, value) ->
+ (key.stripStartOrNull("payment:") ?: return@mapNotNull null) to value
+ }.toMap()
+ ) {
+ // best-effort way to take any method payment as it being available,
+ // otherwise as being unavailable, or undefined
+ mapOf(
+ PaymentMethod.Card to listOf("credit_cards", "debit_cards", "cards"),
+ PaymentMethod.Cash to listOf("cash")
+ ).mapNotNull { (method, values) ->
+ when {
+ values.any { this[it] in listOf("yes", "only") } -> method to true
+ values.any { this[it] == "no" } -> method to false
+ else -> null
+ }
+ }.toMap().takeUnless { it.isEmpty() }
+ }
+ )
+ }
+ }
+}
+
+private fun Map.firstOfAlso(vararg strs: String, also: (String) -> Unit): String? {
+ for (str in strs) {
+ if (str in this) {
+ also(str)
+ return this[str]
+ }
+ }
+ return null
+}
+
+private fun Map.toAddress(): Address? {
+ val formatAddrKeys = this.keys.filter { it.contains("addr") }.toMutableSet()
+ if (formatAddrKeys.isEmpty()) return null
+
+ val addr = Address(
+ city = firstOfAlso("addr:city", "addr:suburb", "addr:hamlet") { formatAddrKeys.remove(it) },
+ state = firstOfAlso("addr:state", "addr:province") { formatAddrKeys.remove(it) },
+ postalCode = firstOfAlso("addr:postcode") { formatAddrKeys.remove(it) },
+ country = firstOfAlso("addr:country") { formatAddrKeys.remove(it) },
+ )
+
+ val formattedRest = buildJsonObject {
+ formatAddrKeys.mapNotNull {
+ val (_, subkey) = it.split(':', limit = 2).takeIf { it.size == 2 }
+ ?: return@mapNotNull null
+ put(subkey, this@toAddress[it])
+ }
+ }.takeIf { it.isNotEmpty() }?.toString()?.runCatching {
+ OsmLocation.addressFormatter.format(
+ this,
+ this@toAddress["addr:country"] ?: Locale.getDefault().country
+ )
+ }?.getOrNull() ?: return addr
+
+ val lines = formattedRest.lines().filter { it.isNotBlank() }
+ return addr.copy(
+ address = lines.getOrNull(0),
+ address2 = lines.getOrNull(1),
+ address3 = lines.getOrNull(2),
+ )
+}
+
+private class MatchAnyReceiverScope {
+ private val pairs = mutableMapOf>()
+ operator fun get(key: T): Pair? = pairs[key]
+ infix fun T.with(pair: Pair) = pairs.put(this, pair)
+}
+
+private fun Map.matchAnyTag(
+ key: String,
+ block: MatchAnyReceiverScope.() -> Unit
+): Pair? {
+ val scope = MatchAnyReceiverScope()
+ scope.block()
+ return this[key]?.split(' ', ',', '.', ';')?.map { it.trim() }
+ ?.firstNotNullOfOrNull { scope[it] }
+}
+
+private fun Map.categorize(context: Context): Pair {
+ val category = this.firstNotNullOfOrNull { (tag, value) ->
+ val values = value.split(' ', ',', '.', ';').map { it.trim() }.toSet()
+ when (tag.lowercase()) {
+
+ "shop" -> values.firstNotNullOfOrNull {
+ when (it) {
+ "florist" -> R.string.poi_category_florist to LocationIcon.Florist
+ "kiosk" -> R.string.poi_category_kiosk to LocationIcon.Kiosk
+ "furniture" -> R.string.poi_category_furniture to LocationIcon.FurnitureStore
+ "cell_phones", "mobile_phone" -> R.string.poi_category_mobile_phone to LocationIcon.CellPhoneStore
+ "books" -> R.string.poi_category_books to LocationIcon.BookStore
+ "clothes" -> R.string.poi_category_clothes to LocationIcon.ClothingStore
+ "convenience" -> R.string.poi_category_convenience to LocationIcon.ConvenienceStore
+ "discount" -> R.string.poi_category_discount_store to LocationIcon.DiscountStore
+ "jewelry" -> R.string.poi_category_jewelry to LocationIcon.JewelryStore
+ "alcohol" -> R.string.poi_category_alcohol to LocationIcon.LiquorStore
+ "pet", "pet_grooming" -> R.string.poi_category_pet to LocationIcon.PetStore
+ "mall", "shopping_centre", "department_store" -> R.string.poi_category_mall to LocationIcon.ShoppingMall
+ "supermarket" -> R.string.poi_category_supermarket to LocationIcon.Supermarket
+ "bakery" -> R.string.poi_category_bakery to LocationIcon.Bakery
+ "optician" -> R.string.poi_category_optician to LocationIcon.Optician
+ "hairdresser" -> R.string.poi_category_hairdresser to LocationIcon.HairSalon
+ "laundry" -> R.string.poi_category_laundry to LocationIcon.Laundromat
+
+ else -> R.string.poi_category_shopping to LocationIcon.Shopping
+ }
+ }
+
+ "amenity" -> values.firstNotNullOfOrNull {
+ when (it) {
+ "place_of_worship" -> matchAnyTag("religion") {
+ "christian" with (R.string.poi_category_church to LocationIcon.Church)
+ "muslim" with (R.string.poi_category_mosque to LocationIcon.Mosque)
+ "buddhist" with (R.string.poi_category_buddhist_temple to LocationIcon.BuddhistTemple)
+ "hindu" with (R.string.poi_category_hindu_temple to LocationIcon.HinduTemple)
+ "jewish" with (R.string.poi_category_synagogue to LocationIcon.Synagogue)
+ } ?: (R.string.poi_category_place_of_worship to LocationIcon.PlaceOfWorship)
+
+ "fast_food" -> R.string.poi_category_fast_food to LocationIcon.FastFood
+ "cafe" -> R.string.poi_category_cafe to LocationIcon.Cafe
+ "ice_cream" -> R.string.poi_category_ice_cream to LocationIcon.IceCream
+ "bar" -> R.string.poi_category_bar to LocationIcon.Bar
+ "pub" -> R.string.poi_category_pub to LocationIcon.Pub
+ "restaurant" -> matchAnyTag("cuisine") {
+ "pizza" with (R.string.poi_category_pizza_restaurant to LocationIcon.Pizza)
+ "burger" with (R.string.poi_category_burger_restaurant to LocationIcon.Burger)
+ "chinese" with (R.string.poi_category_chinese_restaurant to LocationIcon.Ramen)
+ "ramen" with (R.string.poi_category_ramen_restaurant to LocationIcon.Ramen)
+ "japanese" with (R.string.poi_category_japanese_restaurant to LocationIcon.JapaneseCuisine)
+ "kebab" with (R.string.poi_category_kebab_restaurant to LocationIcon.Kebab)
+ "asian" with (R.string.poi_category_asian_restaurant to LocationIcon.AsianCuisine)
+ "soup" with (R.string.poi_category_soup_restaurant to LocationIcon.Soup)
+ "coffee_shop" with (R.string.poi_category_cafe to LocationIcon.Cafe)
+ "brunch" with (R.string.poi_category_brunch_restaurant to LocationIcon.Brunch)
+ } ?: (R.string.poi_category_restaurant to LocationIcon.Restaurant)
+
+ "fuel" -> R.string.poi_category_fuel to LocationIcon.GasStation
+ "car_rental" -> R.string.poi_category_car_rental to LocationIcon.CarRental
+ "car_sharing" -> R.string.poi_category_car_sharing to LocationIcon.CarRental
+ "car_wash" -> R.string.poi_category_car_wash to LocationIcon.CarWash
+ "charging_station" -> R.string.poi_category_charging_station to LocationIcon.ChargingStation
+ "parking", "parking_space", "motorcycle_parking" -> R.string.poi_category_parking to LocationIcon.Parking
+ "motorcycle_rental" -> R.string.poi_category_motorcycle_rental to LocationIcon.Motorcycle
+
+ "theatre" -> R.string.poi_category_theater to LocationIcon.Theater
+ "cinema" -> R.string.poi_category_cinema to LocationIcon.MovieTheater
+ "nightclub" -> R.string.poi_category_nightclub to LocationIcon.NightClub
+ "concert_hall" -> R.string.poi_category_concert_hall to LocationIcon.ConcertHall
+ "casino" -> R.string.poi_category_casino to LocationIcon.Casino
+
+ "pharmacy" -> R.string.poi_category_pharmacy to LocationIcon.Pharmacy
+ "bank" -> R.string.poi_category_bank to LocationIcon.Bank
+ "atm" -> R.string.poi_category_atm to LocationIcon.Atm
+ "doctors" -> R.string.poi_category_doctors to LocationIcon.Physician
+ "dentist" -> R.string.poi_category_dentist to LocationIcon.Dentist
+ "hospital" -> R.string.poi_category_hospital to LocationIcon.Hospital
+ "clinic" -> R.string.poi_category_clinic to LocationIcon.Clinic
+
+ "police" -> R.string.poi_category_police to LocationIcon.Police
+ "fire_station" -> R.string.poi_category_fire_station to LocationIcon.FireDepartment
+ "courthouse" -> R.string.poi_category_courthouse to LocationIcon.Courthouse
+ "post_office" -> R.string.poi_category_post_office to LocationIcon.PostOffice
+ "library" -> R.string.poi_category_library to LocationIcon.Library
+ "school" -> R.string.poi_category_school to LocationIcon.School
+ "university" -> R.string.poi_category_university to LocationIcon.University
+ "toilets" -> R.string.poi_category_toilets to LocationIcon.PublicBathroom
+ "townhall" -> R.string.poi_category_townhall to LocationIcon.GovernmentBuilding
+
+ else -> null
+ }
+ }
+
+ "tourism" -> values.firstNotNullOfOrNull {
+ when (it) {
+ "gallery" -> R.string.poi_category_gallery to LocationIcon.ArtGallery
+ "museum" -> R.string.poi_category_museum to LocationIcon.Museum
+ "theme_park" -> R.string.poi_category_amusement_park to LocationIcon.AmusementPark
+ "hotel" -> R.string.poi_category_hotel to LocationIcon.Hotel
+ else -> null
+ }
+ }
+
+ "leisure" -> values.firstNotNullOfOrNull {
+ when (it) {
+ "stadium" -> R.string.poi_category_stadium to LocationIcon.Stadium
+ "fitness_centre" -> R.string.poi_category_fitness_center to LocationIcon.FitnessCenter
+ "swimming_pool" -> R.string.poi_category_swimming to LocationIcon.Swimming
+ "pitch", "sports_centre" -> matchAnyTag("sport") {
+ "soccer" with (R.string.poi_category_soccer to LocationIcon.Soccer)
+ "tennis" with (R.string.poi_category_tennis to LocationIcon.Tennis)
+ "basketball" with (R.string.poi_category_basketball to LocationIcon.Basketball)
+ "gymnastics" with (R.string.poi_category_gymnastics to LocationIcon.Gymnastics)
+ "martial_arts" with (R.string.poi_category_martial_arts to LocationIcon.MartialArts)
+ "ice_hockey" with (R.string.poi_category_ice_hockey to LocationIcon.Hockey)
+ "baseball" with (R.string.poi_category_baseball to LocationIcon.Baseball)
+ "american_football" with (R.string.poi_category_american_football to LocationIcon.AmericanFootball)
+ "handball" with (R.string.poi_category_handball to LocationIcon.Handball)
+ "volleyball" with (R.string.poi_category_volleyball to LocationIcon.Volleyball)
+ "skiing" with (R.string.poi_category_skiing to LocationIcon.Skiing)
+ "cricket" with (R.string.poi_category_cricket to LocationIcon.Cricket)
+ }
+
+ "golf_course" -> R.string.poi_category_golf to LocationIcon.Golf
+ "park" -> R.string.poi_category_park to LocationIcon.Park
+ else -> null
+ }
+ }
+
+ "historic" -> values.firstNotNullOfOrNull {
+ when (it) {
+ "monument" -> R.string.poi_category_monument to LocationIcon.Monument
+ else -> null
+ }
+ }
+
+ "building" -> values.firstNotNullOfOrNull {
+ when (it) {
+ "government" -> R.string.poi_category_government_building to LocationIcon.GovernmentBuilding
+ else -> null
+ }
+ }
+
+ else -> null
+ }
+ }
+ val (rid, icon) = category ?: return null to null
+ return context.resources.getString(rid) to icon
+}
+
+internal fun parseOpeningSchedule(
+ openingHours: String?,
+ localTime: LocalDateTime = LocalDateTime.now()
+): OpeningSchedule? {
+ val parsed = openingHours?.toOpeningHoursOrNull(lenient = true) ?: return null
+
+ if (parsed.rules.singleOrNull()?.selector is TwentyFourSeven) {
+ return OpeningSchedule.TwentyFourSeven
+ }
+
+ val rangeRules = parsed.rules.mapNotNull { it.selector as? Range }
+
+ // Group rules by the weekdays they apply to. Rules can apply to multiple weekdays.
+ val rulesMap = mutableMapOf>(
+ Weekday.Monday to mutableListOf(),
+ Weekday.Tuesday to mutableListOf(),
+ Weekday.Wednesday to mutableListOf(),
+ Weekday.Thursday to mutableListOf(),
+ Weekday.Friday to mutableListOf(),
+ Weekday.Saturday to mutableListOf(),
+ Weekday.Sunday to mutableListOf()
+ )
+
+ for (rule in rangeRules) {
+ if (rule.weekdays != null) {
+ for (selector in rule.weekdays!!) {
+ when (selector) {
+ is Weekday -> rulesMap[selector]!!.add(rule)
+ is WeekdayRange -> {
+ for (weekday in selector.start.ordinal..selector.end.ordinal) {
+ rulesMap[Weekday.entries[weekday]]!!.add(rule)
+ }
+ }
+ is SpecificWeekdays -> {
+ rulesMap[selector.weekday]!!.add(rule)
+ }
+ }
+ }
+ } else if (!rule.holidays.isNullOrEmpty()) {
+ continue // skip PH and SH entries
+ } else if (!rule.times.isNullOrEmpty() || !rule.months.isNullOrEmpty()) {
+ rulesMap.forEach { _, it ->
+ it.add(rule)
+ }
+ }
+ }
+
+ // Filter out rules that are not valid for the current year, month, and week.
+ val applicableRules = rulesMap.flatMap { (day, rules) ->
+ rules.filterYears(localTime)
+ .filterMonths(localTime)
+ .filterNthDays(localTime)
+ .map { it.copy(weekdays = listOf(day)) }
+ }
+
+ val hours = mutableSetOf()
+
+ for (range in applicableRules) {
+
+ val localTimesWithDuration =
+ range.times?.mapNotNull { it.toLocalTimeWithDuration() } ?: continue
+ val daysOfWeek = range.weekdays
+ .ifNullOrEmpty { Weekday.entries.toList() }
+ .flatMap { it.toDaysOfWeek() }
+
+ hours += daysOfWeek.flatMap { dow ->
+ localTimesWithDuration.map {
+ val (start, dur) = it
+ OpeningHours(dow, start, dur)
+ }
+ }
+ }
+
+ return OpeningSchedule.Hours(hours)
+}
+
+/**
+ * Returns only the rules that are valid for the given year.
+ */
+private fun List.filterYears(localTime: LocalDateTime): List {
+ if (all { it.years.isNullOrEmpty() }) return this
+
+ val thisYear = filter {
+ it.years?.any {
+ when (it) {
+ is Year -> it.year == localTime.year
+ is StartingAtYear -> it.start <= localTime.year
+ is YearRange -> localTime.year in it.start..it.end step (it.step ?: 1)
+ }
+ } == true
+ }
+
+ if (!thisYear.isEmpty()) return thisYear
+
+ return filter { it.years.isNullOrEmpty() }
+}
+
+/**
+ * Returns only the rules that are valid for the given month.
+ */
+private fun List.filterMonths(localTime: LocalDateTime): List {
+ if (all { it.months.isNullOrEmpty() }) return this
+
+ val thisMonth = filter {
+ it.months?.any {
+ when (it) {
+ is MonthRange -> (it.year?.let { it == localTime.year } != false) && localTime.month.ordinal in it.start.ordinal..it.end.ordinal
+
+ is SingleMonth -> (it.year?.let { it == localTime.year } != false) && localTime.month.ordinal == it.month.ordinal
+
+ else -> false
+ }
+ } == true
+ }
+
+ if (!thisMonth.isEmpty()) return thisMonth
+
+ return filter { it.months.isNullOrEmpty() }
+}
+
+/**
+ * Returns only the rules that are valid for the given week.
+ * (i.e. if the given week is the 2nd week of the month, it will return only the rules that are
+ * valid for the 2nd week of the month)
+ */
+private fun List.filterNthDays(localTime: LocalDateTime): List {
+ if (none { it.weekdays?.any { it is SpecificWeekdays } == true }) return this
+
+ val currentWeek = localTime.getNthWeekdaysOfCurrentWeek()
+
+ val specific = mapNotNull { range ->
+ (range.weekdays?.singleOrNull() as? SpecificWeekdays)?.let {
+ range to it
+ }
+ }
+
+ val rule = specific.firstOrNull { (_, specific) ->
+ currentWeek.any { (dow, nthFwd, nthBwd) ->
+ specific.weekday.ordinal == dow.ordinal && specific.nths.any {
+ when (it) {
+ is Nth -> it.nth == nthFwd
+ is NthRange -> nthFwd in it.start..it.end
+ is LastNth -> it.nth == nthBwd
+ }
+ }
+ }
+ }
+
+ if (rule != null) return listOf(rule.first)
+
+ return listOfNotNull(
+ singleOrNull { it.weekdays?.singleOrNull() !is SpecificWeekdays }
+ )
+}
+
+private fun WeekdaysSelector.toDaysOfWeek(): List {
+ return when (this) {
+ is Weekday -> listOf(
+ when (this) {
+ Weekday.Monday -> DayOfWeek.MONDAY
+ Weekday.Tuesday -> DayOfWeek.TUESDAY
+ Weekday.Wednesday -> DayOfWeek.WEDNESDAY
+ Weekday.Thursday -> DayOfWeek.THURSDAY
+ Weekday.Friday -> DayOfWeek.FRIDAY
+ Weekday.Saturday -> DayOfWeek.SATURDAY
+ Weekday.Sunday -> DayOfWeek.SUNDAY
+ }
+ )
+
+ is WeekdayRange -> (start to end).map { it.toDaysOfWeek().single().value }
+ .into { start, end -> (start..end).map { DayOfWeek.of(it) } }
+
+ is SpecificWeekdays -> weekday.toDaysOfWeek()
+ }
+}
+
+
+private fun TimesSelector.toLocalTimeWithDuration(): Pair? {
+ if (this !is TimeSpan) return null
+
+ val start = start as? ClockTime ?: return null
+ val end = end as? ExtendedClockTime ?: return null
+
+ return LocalTime.of(
+ start.hour,
+ start.minutes
+ ) to Duration.ofMinutes(
+ (Math.floorMod(
+ end.hour - start.hour,
+ 24
+ ) * 60 + end.minutes - start.minutes).toLong()
+ )
+}
+
+/**
+ * Calculates the ordinal position of each day of the current week (Monday to Sunday) within the month.
+ *
+ * @receiver LocalDateTime The current date and time.
+ * @return A list of triples, where each triple contains:
+ * - The `DayOfWeek` (e.g., Monday, Tuesday, etc.).
+ * - The forward ordinal position of the day in the month (e.g., 1st Monday, 2nd Tuesday, etc.).
+ * - The backward ordinal position of the day in the month (e.g., last Monday, 2nd last Tuesday, etc.).
+ *
+ * Example:
+ * If today is the 15th of a month (Wednesday), the function will return:
+ * - For Monday: `(DayOfWeek.MONDAY, 3, 2)` (3rd Monday of the month, 2nd last Monday of the month).
+ * - For Wednesday: `(DayOfWeek.WEDNESDAY, 3, 2)` (3rd Wednesday of the month, 2nd last Wednesday of the month).
+ */
+private fun LocalDateTime.getNthWeekdaysOfCurrentWeek(): List> {
+ val monday = with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
+ return (0 until 7).map { i ->
+ val (nth, nthLast) = monday.plusDays(i.toLong()).let { weekday ->
+ (
+ ChronoUnit.WEEKS.between(
+ with(TemporalAdjusters.firstInMonth(weekday.dayOfWeek)),
+ weekday
+ ).toInt() + 1
+ ) to (
+ ChronoUnit.WEEKS.between(
+ weekday,
+ with(TemporalAdjusters.lastInMonth(weekday.dayOfWeek))
+ ).toInt() + 1
+ )
+ }
+ Triple(DayOfWeek.entries[i], nth, nthLast)
+ }
+}
\ No newline at end of file
diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/googleapi/OsmLocationProvider.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/googleapi/OsmLocationProvider.kt
new file mode 100644
index 00000000..c59019e7
--- /dev/null
+++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/googleapi/OsmLocationProvider.kt
@@ -0,0 +1,253 @@
+package de.mm20.launcher2.locations.providers.googleapi
+
+import android.content.Context
+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.flow.SharingStarted
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import retrofit2.HttpException
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.net.UnknownHostException
+
+private val Scope = CoroutineScope(Job() + Dispatchers.IO)
+private val HttpClient = OkHttpClient()
+
+internal class OsmLocationProvider(
+ private val context: Context,
+ settings: LocationSearchSettings
+) : LocationProvider {
+
+ private val overpassApi = settings.overpassUrl.map {
+ try {
+ Retrofit.Builder()
+ .client(HttpClient)
+ .baseUrl(it?.takeIf { it.isNotBlank() }
+ ?: LocationSearchSettings.DefaultOverpassUrl)
+ .addConverterFactory(OverpassQueryConverterFactory())
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ .create(OverpassApi::class.java)
+ } catch (e: Exception) {
+ CrashReporter.logException(e)
+ null
+ }
+ }.stateIn(Scope, SharingStarted.Eagerly, null)
+
+
+ suspend fun update(
+ id: Long
+ ): UpdateResult = overpassApi.first()?.runCatching {
+ this.search(
+ OverpassIdQuery(
+ id = id
+ )
+ ).let {
+ OsmLocation.fromOverpassResponse(it, context)
+ }.first().apply {
+ updatedSelf = { update(id) }
+ }
+ }?.fold(
+ onSuccess = { UpdateResult.Success(it) },
+ onFailure = {
+ when (it) {
+ is CancellationException, is UnknownHostException -> {
+ // network
+ UpdateResult.TemporarilyUnavailable(it)
+ }
+
+ is HttpException -> when (it.code()) {
+ in 400..499 -> UpdateResult.PermanentlyUnavailable(it)
+ else -> UpdateResult.TemporarilyUnavailable(it)
+ }
+
+ is NoSuchElementException -> {
+ // empty response
+ UpdateResult.PermanentlyUnavailable(it)
+ }
+
+ else -> {
+ if (it is Exception) {
+ CrashReporter.logException(it)
+ }
+ UpdateResult.TemporarilyUnavailable(it)
+ }
+ }
+ }
+ ) ?: let {
+ Log.e("OsmProvider", "overpassApi was not initialized")
+ UpdateResult.TemporarilyUnavailable()
+ }
+
+ override suspend fun search(
+ query: String,
+ userLocation: AndroidLocation,
+ allowNetwork: Boolean,
+ searchRadiusMeters: Int,
+ hideUncategorized: Boolean,
+ ): List {
+ if (!allowNetwork || query.length < 2) {
+ return emptyList()
+ }
+
+ withContext(Dispatchers.IO) {
+ HttpClient.dispatcher.cancelAll()
+ }
+
+ 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: $query", it)
+ }
+ }?.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
+ }
+ }?.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/googleapi/OverpassApi.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/googleapi/OverpassApi.kt
new file mode 100644
index 00000000..690d9b08
--- /dev/null
+++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/googleapi/OverpassApi.kt
@@ -0,0 +1,126 @@
+package de.mm20.launcher2.locations.providers.googleapi
+
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import retrofit2.Converter
+import retrofit2.Retrofit
+import retrofit2.http.Body
+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(
+ /**
+ * 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,
+)
+
+data class OverpassIdQuery(
+ val id: Long,
+)
+
+data class OverpassResponse(
+ val elements: List,
+)
+
+data class OverpassResponseElementCenter(
+ val lat: Double,
+ val lon: Double,
+)
+
+data class OverpassResponseElement(
+ val type: String,
+ val id: Long,
+ val lat: Double?,
+ val lon: Double?,
+ val center: OverpassResponseElementCenter?,
+ val tags: Map?,
+)
+
+interface OverpassApi {
+ @POST("api/interpreter")
+ suspend fun search(@Body data: OverpassFuzzyRadiusQuery): OverpassResponse
+
+ @POST("api/interpreter")
+ suspend fun search(@Body data: OverpassIdQuery): OverpassResponse
+}
+
+class OverpassFuzzyRadiusQueryConverter : Converter {
+ override fun convert(value: OverpassFuzzyRadiusQuery): RequestBody {
+ val encodedQuery = value.query.split(' ')
+ .joinToString(
+ separator = ".*",
+ prefix = "\"",
+ postfix = "\""
+ ) { Regex.escapeReplacement(it) }
+
+ val overpassQlBuilder = StringBuilder()
+ val latDegreeChange = value.radius * 0.00001 / 1.11
+ val lonDegreeChange = latDegreeChange / cos(Math.toRadians(value.latitude))
+ val boundingBox = arrayOf(
+ value.latitude - latDegreeChange, value.longitude - lonDegreeChange,
+ value.latitude + latDegreeChange, value.longitude + lonDegreeChange
+ )
+ overpassQlBuilder.append("[out:json][timeout:10][bbox:" + boundingBox.joinToString(",") + "];")
+
+ overpassQlBuilder.append("(")
+ overpassQlBuilder.append("nw[name~$encodedQuery,i];")
+ overpassQlBuilder.append("nw[brand~$encodedQuery,i];")
+ for (tag in value.tagGroups) {
+ val tags = tag.split(',')
+
+ if (tags.isEmpty()) continue
+
+ overpassQlBuilder.append("nw[${tags.joinToString("][")}];")
+ }
+ overpassQlBuilder.append(");")
+ // center to add the center coordinate of a way to the result, if applicable
+ overpassQlBuilder.append("out center;")
+
+ return overpassQlBuilder.toString().toRequestBody()
+ }
+}
+
+class OverpassIdQueryConverter : Converter {
+ override fun convert(value: OverpassIdQuery): RequestBody = """
+ [out:json];
+ nw(${value.id});
+ out center;
+ """.trimIndent().toRequestBody()
+}
+
+class OverpassQueryConverterFactory : Converter.Factory() {
+ override fun requestBodyConverter(
+ type: Type,
+ parameterAnnotations: Array,
+ methodAnnotations: Array,
+ retrofit: Retrofit
+ ): Converter<*, RequestBody>? {
+ if (type == OverpassFuzzyRadiusQuery::class.java)
+ return OverpassFuzzyRadiusQueryConverter()
+
+ if (type == OverpassIdQuery::class.java)
+ return OverpassIdQueryConverter()
+
+ return null
+ }
+}
+
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 fbb2bc47..8793c4e6 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
@@ -102,7 +102,7 @@ internal class OsmLocationProvider(
searchRadiusMeters: Int,
hideUncategorized: Boolean,
): List {
- if (!allowNetwork || query.length < 2) {
+ if (!allowNetwork || query.length < 1) {
return emptyList()
}
@@ -243,11 +243,20 @@ internal class OsmLocationProvider(
).mapKeys { context.getString(it.key) }
private fun delocalizeToQueryableTags(localizedQuery: String): List =
+
poiCategories.mapNotNull { (string, tags) ->
- val score = ResultScore(
- localizedQuery,
- primaryFields = listOf(string)
- )
+ val score = if (string.contains("|")) {
+ ResultScore(
+ localizedQuery,
+ primaryFields = string.split("|")
+ )
+ } else {
+ ResultScore(
+ localizedQuery,
+ primaryFields = listOf(string)
+ )
+ }
+
if (score.score > 0.8f) tags else null
}
}
diff --git a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NextcloudApiHelper.kt b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NextcloudApiHelper.kt
index 27d3c3f2..3003788a 100644
--- a/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NextcloudApiHelper.kt
+++ b/libs/nextcloud/src/main/java/de/mm20/launcher2/nextcloud/NextcloudApiHelper.kt
@@ -231,8 +231,12 @@ class NextcloudApiHelper(val context: Context) {
.url("$server/ocs/v2.php/core/apppassword")
.build()
withContext(Dispatchers.IO) {
- val response = httpClient.newCall(request).execute()
- response
+ try {
+ val response = httpClient.newCall(request).execute()
+ response
+ } catch (e: IOException) {
+ Log.e("NextcloudApiHelper", "Error during Nextcloud logout", e)
+ }
}
preferences.edit {
putString("server", null)
diff --git a/services/widgets/src/main/java/de/mm20/launcher2/services/widgets/WidgetsService.kt b/services/widgets/src/main/java/de/mm20/launcher2/services/widgets/WidgetsService.kt
index b4184257..a2766c13 100644
--- a/services/widgets/src/main/java/de/mm20/launcher2/services/widgets/WidgetsService.kt
+++ b/services/widgets/src/main/java/de/mm20/launcher2/services/widgets/WidgetsService.kt
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProviderInfo
import android.content.Context
import android.content.pm.LauncherApps
+import android.os.Build
import androidx.core.content.getSystemService
import de.mm20.launcher2.widgets.CalendarWidget
import de.mm20.launcher2.widgets.FavoritesWidget
@@ -32,6 +33,14 @@ class WidgetsService(
for (profile in profiles) {
widgets.addAll(appWidgetManager.getInstalledProvidersForProfile(profile))
}
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ // Ignore widgets that the launcher is not supposed to access
+ widgets.filter {
+ it.widgetFeatures and AppWidgetProviderInfo.WIDGET_FEATURE_HIDE_FROM_PICKER == 0
+ }
+ } else {
+ widgets
+ }
widgets
}