This commit is contained in:
lunaticbum 2025-08-13 10:17:00 +09:00
parent 81e4c77644
commit b8b088c1ab
16 changed files with 1052 additions and 46 deletions

View File

@ -73,5 +73,6 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="UsePropertyAccessSyntax" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
</profile>
</component>

View File

@ -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"

View File

@ -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">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@ -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()

View File

@ -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 <T> SnapshotStateList<T>.mergeWith(newItems: List<T>?) {
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 <T> SnapshotStateList<T>.updateItems(newItems: List<T>?) {
clear()
addAll(newItems ?: emptyList())
}
private suspend fun <T : SavableSearchable> SnapshotStateList<T>.mergeWith(
newItems: List<T>?,
hiddenKeys: List<String>,
query: String
) = this.mergeWith((newItems ?: emptyList()).filterNot { hiddenKeys.contains(it.key) }
.applyRanking(query))
}

View File

@ -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) }

View File

@ -66,7 +66,7 @@ fun WidgetColumn(
Column(
modifier = modifier
modifier = modifier.fillMaxWidth()
) {
val scope = rememberCoroutineScope()
Column {

View File

@ -693,4 +693,4 @@ fun ConfigureClockWidgetSheet(
}
}
}
}
}

View File

@ -442,7 +442,7 @@
<string name="poi_category_books">서점</string>
<string name="poi_category_florist">꽃집</string>
<string name="poi_category_church">교회</string>
<string name="poi_category_restaurant">레스토랑</string>
<string name="poi_category_restaurant">레스토랑|음식점|식당|밥집|분식</string>
<string name="poi_category_fast_food">패스트푸드</string>
<string name="poi_category_hotel">호텔</string>
<string name="poi_category_cafe">카페</string>

View File

@ -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(

View File

@ -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<Location>)? = null,
override val userRating: Float?,
override val acceptedPaymentMethods: Map<PaymentMethod, Boolean>?
) : Location, UpdatableSearchable<Location> {
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<Departure>? = 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<OsmLocation> = 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<String, String>.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<String, String>.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<T, A, B> {
private val pairs = mutableMapOf<T, Pair<A, B>>()
operator fun get(key: T): Pair<A, B>? = pairs[key]
infix fun T.with(pair: Pair<A, B>) = pairs.put(this, pair)
}
private fun <A, B> Map<String, String>.matchAnyTag(
key: String,
block: MatchAnyReceiverScope<String, A, B>.() -> Unit
): Pair<A, B>? {
val scope = MatchAnyReceiverScope<String, A, B>()
scope.block()
return this[key]?.split(' ', ',', '.', ';')?.map { it.trim() }
?.firstNotNullOfOrNull { scope[it] }
}
private fun Map<String, String>.categorize(context: Context): Pair<String?, LocationIcon?> {
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<Int, LocationIcon>("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<Int, LocationIcon>("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<Int, LocationIcon>("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, MutableList<Range>>(
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<OpeningHours>()
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<Range>.filterYears(localTime: LocalDateTime): List<Range> {
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<Range>.filterMonths(localTime: LocalDateTime): List<Range> {
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<Range>.filterNthDays(localTime: LocalDateTime): List<Range> {
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<DayOfWeek> {
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<LocalTime, Duration>? {
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<Triple<DayOfWeek, Int, Int>> {
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)
}
}

View File

@ -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<Long> {
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<Location> = 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<Location> {
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<String> =
poiCategories.mapNotNull { (string, tags) ->
val score = ResultScore(
localizedQuery,
primaryFields = listOf(string)
)
if (score.score > 0.8f) tags else null
}
}

View File

@ -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<String>,
val radius: Int,
val latitude: Double,
val longitude: Double,
)
data class OverpassIdQuery(
val id: Long,
)
data class OverpassResponse(
val elements: List<OverpassResponseElement>,
)
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<String, String>?,
)
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<OverpassFuzzyRadiusQuery, RequestBody> {
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<OverpassIdQuery, RequestBody> {
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<out Annotation>,
methodAnnotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<*, RequestBody>? {
if (type == OverpassFuzzyRadiusQuery::class.java)
return OverpassFuzzyRadiusQueryConverter()
if (type == OverpassIdQuery::class.java)
return OverpassIdQueryConverter()
return null
}
}

View File

@ -102,7 +102,7 @@ internal class OsmLocationProvider(
searchRadiusMeters: Int,
hideUncategorized: Boolean,
): List<Location> {
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<String> =
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
}
}

View File

@ -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)

View File

@ -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
}