Add support for location plugins (#772)

* Cherry pick location refactor

* Refactor :data:openstreetmaps to :data:locations

* contract, plugin sdk

* Implement serialization, module tweaks

* Include check for out-of-date departures in SearchableItemVM.requestUpdatedSearchable()

* settings for location plugins

* Try not to be too lazy

* more fiddling with the plugin SDK

* add departures in MapView with mock data for debug builds

* change icons

* add boats

* animate departure lazycolumn

* Add MarqueeText for text overflow handling

* Define height for Departures in case there is no map to display

* Don't inclure railway / highway tags for OSM since there will be location plugins for that

* sort by time

* - Apply pre-merge changes to LocationSettings
- Add banner warning about slowed down location search for large search radii

* ditch `showLocationOnMap`

* LocationItem: make `showOpeningSchedule` toggleable

* LocationItem: make Navigation AssistChip work for people that don't have google maps installed

* LocationItem: resolve TODOs

* MapTiles: ditch unused code, animate userIndicator

* Reintroduce departure list

* Add LineColor

* Add osm tag `stars` as `userRating` https://taginfo.openstreetmap.org/keys/stars#overview

* typealias -> import

* Don't add Navigation Chip when there is no way to resolve navigation intents

* Add settings migration

* Set plugin SDK version to 1.2.0-SNAPSHOT

* Deduplicate shared plugin classes, use kotlinx.serialization

* Fix imports

* Use ZonedDateTime for depature times

* Add more line types

* Rewrite location serialization

* Replace street/houseNumber with address

* Add attribution field

* Add plugin config

* Reject location search requests without lat lon parameters

* Add default values to plugin location class

* Don't crash if column value is null

* Add docs comments to LocationCategory values

* Refactor OpeningSchedule as polymorphic

* remove dead corpse *ahem* code

* Split LocationCategory into category and icon

(Also update to Kotlin 2.0, please don't do this at home)

* Add more location icons

* Fix (?) location deserializer

* Add more location icons

* More icons

* Meh

* Add Pub

* Disable Github Maven repo if credentials are missing

* Add location search specific settings to plugin details screen

* Add language parameter

* Unbreak the build

* Refactor plugin SDK (with breaking changes)

* Set plugin SDK version to 2.0.0-SNAPSHOT

* Document SDK breaking changes

* Implement LocationProvider.getQuery

* Add a typesafe cursor API

* Oops I did it again

next time maybe check if the code is actually compiling before pushing

* Add missing return statement

* Fix list serialization

* Departure time UI adjustment

* Use typesafe cursor for weather plugins

* Add userRatingCount and emailAddress fields

* grrr

* Rename and extend LineTypes

* Add default lineType to Departure to fix serialization errors

* Fix refreshing stored plugin locations

* Adapt line name column width to available departures

* Fix plugin settings screen category overlap

* add LocationItem.GenericTransit

* Fix crash during deserialization of locations

* Update SDK docs

* Replace plugin "official" mark with "verified developer" mark

before anyone gets sued

* show 'now' when departure is in less than one minute

* Add typesafe Bundle API

* Implement plugin API changes

* Plugin SDK: Fix refresh result not being returned

* Update docs

* apply alpha to departures that have departured

* better (maybe): reduce saturation instead of alpha

* Add default values for Attribution

* Display attribution

* Rearrange location result layout

* Reduce searchable update interval to 1 minute

* Pass last update time to refresh function

* Change refresh path and ensure that timestamp is only update when the item was updated

* categorize osm location

* Update docs

* Optimize location search

- run providers in parallel
- flatten code

* add experimental address parsing for OSM

* add poi_category_townhall

* Fix popup closing when favorites items are updated

* Revert "Fix popup closing when favorites items are updated"

This reverts commit fc517fd066c7f8109b6d6df2d4f536af66398207.

* Fork AndroidAddressFormatter to `:libs:address-formatter`

* migrate `:libs:address-formatter` dependencies to version catalog and update them

* also consider addr:{suburb,hamlet} for `Address.city` if city tag is missing

* Move poi strings back to strings.xml

* Update Jetpack Compose

* Move address-formatter back to its original package, add license and readme

* Move address-formatter back to its original package

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph
2024-06-14 11:57:03 +02:00
committed by GitHub
co-authored by MM20
parent cfe80ff3e5
commit 65a9c8c1fe
145 changed files with 5804 additions and 2199 deletions
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
</manifest>
@@ -0,0 +1,198 @@
package de.mm20.launcher2.locations
import android.content.Context
import android.util.Log
import de.mm20.launcher2.locations.providers.PluginLocation
import de.mm20.launcher2.locations.providers.PluginLocationProvider
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocation
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocationProvider
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.config.StorageStrategy
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.asUpdateResult
import de.mm20.launcher2.search.location.Address
import de.mm20.launcher2.search.location.Attribution
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningSchedule
import de.mm20.launcher2.serialization.Json
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@Serializable
internal data class SerializedLocation(
val id: String? = null,
val lat: Double? = null,
val lon: Double? = null,
val icon: LocationIcon? = null,
val category: String? = null,
val label: String? = null,
val address: Address? = null,
val websiteUrl: String? = null,
val phoneNumber: String? = null,
val emailAddress: String? = null,
val userRating: Float? = null,
val userRatingCount: Int? = null,
val openingSchedule: OpeningSchedule? = null,
val timestamp: Long? = null,
val departures: List<Departure>? = null,
val fixMeUrl: String? = null,
val attribution: Attribution? = null,
val authority: String? = null,
val storageStrategy: StorageStrategy? = null,
)
internal class OsmLocationSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as OsmLocation
return Json.Lenient.encodeToString(
SerializedLocation(
id = searchable.id.toString(),
lat = searchable.latitude,
lon = searchable.longitude,
icon = searchable.icon,
category = searchable.category,
label = searchable.label,
address = searchable.address,
websiteUrl = searchable.websiteUrl,
phoneNumber = searchable.phoneNumber,
emailAddress = searchable.emailAddress,
userRating = searchable.userRating,
userRatingCount = searchable.userRatingCount,
openingSchedule = searchable.openingSchedule,
timestamp = searchable.timestamp,
departures = searchable.departures,
fixMeUrl = searchable.fixMeUrl,
)
)
}
override val typePrefix: String
get() = "osmlocation"
}
internal class OsmLocationDeserializer(
private val osmProvider: OsmLocationProvider,
) : SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable? {
val json = Json.Lenient.decodeFromString<SerializedLocation>(serialized)
val id = json.id?.toLongOrNull() ?: return null
return OsmLocation(
id = id,
latitude = json.lat ?: return null,
longitude = json.lon ?: return null,
icon = json.icon,
category = json.category,
label = json.label ?: return null,
address = json.address,
websiteUrl = json.websiteUrl,
phoneNumber = json.phoneNumber,
emailAddress = json.emailAddress,
userRating = json.userRating,
openingSchedule = json.openingSchedule,
timestamp = json.timestamp ?: return null,
updatedSelf = {
osmProvider.update(id)
}
)
}
}
internal class PluginLocationSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as PluginLocation
return when (searchable.storageStrategy) {
StorageStrategy.StoreReference -> Json.Lenient.encodeToString(
SerializedLocation(
id = searchable.id,
authority = searchable.authority,
storageStrategy = StorageStrategy.StoreReference,
)
)
else -> {
Json.Lenient.encodeToString(
SerializedLocation(
id = searchable.id,
lat = searchable.latitude,
lon = searchable.longitude,
icon = searchable.icon,
category = searchable.category,
label = searchable.label,
address = searchable.address,
websiteUrl = searchable.websiteUrl,
phoneNumber = searchable.phoneNumber,
emailAddress = searchable.emailAddress,
userRating = searchable.userRating,
userRatingCount = searchable.userRatingCount,
attribution = searchable.attribution,
openingSchedule = searchable.openingSchedule,
timestamp = searchable.timestamp,
departures = searchable.departures,
fixMeUrl = searchable.fixMeUrl,
authority = searchable.authority,
storageStrategy = searchable.storageStrategy,
)
)
}
}
}
override val typePrefix: String
get() = PluginLocation.DOMAIN
}
internal class PluginLocationDeserializer(
private val context: Context,
private val pluginRepository: PluginRepository,
) : SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable? {
val json = Json.Lenient.decodeFromString<SerializedLocation>(serialized)
val authority = json.authority ?: return null
val id = json.id ?: return null
val strategy = json.storageStrategy ?: StorageStrategy.StoreCopy
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
if (!plugin.enabled) return null
return when (strategy) {
StorageStrategy.StoreReference -> {
PluginLocationProvider(context, authority).get(id).getOrNull()
}
else -> {
val timestamp = json.timestamp ?: 0
PluginLocation(
id = id,
latitude = json.lat ?: return null,
longitude = json.lon ?: return null,
icon = json.icon,
category = json.category,
label = json.label ?: return null,
address = json.address,
websiteUrl = json.websiteUrl,
phoneNumber = json.phoneNumber,
emailAddress = json.emailAddress,
userRating = json.userRating,
userRatingCount = json.userRatingCount,
openingSchedule = json.openingSchedule,
timestamp = timestamp,
departures = json.departures,
fixMeUrl = json.fixMeUrl,
attribution = json.attribution,
authority = authority,
storageStrategy = strategy,
updatedSelf = {
if (it !is PluginLocation) UpdateResult.TemporarilyUnavailable()
else PluginLocationProvider(context, authority).refresh(it, timestamp).asUpdateResult()
}
)
}
}
}
}
@@ -0,0 +1,86 @@
package de.mm20.launcher2.locations
import android.content.Context
import de.mm20.launcher2.devicepose.DevicePoseProvider
import de.mm20.launcher2.locations.providers.PluginLocationProvider
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocationProvider
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SearchableRepository
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.coroutineContext
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.newCoroutineContext
import kotlinx.coroutines.supervisorScope
internal class LocationsRepository(
private val context: Context,
private val settings: LocationSearchSettings,
private val poseProvider: DevicePoseProvider,
private val permissionsManager: PermissionsManager,
) : SearchableRepository<Location> {
override fun search(
query: String,
allowNetwork: Boolean
): Flow<ImmutableList<Location>> {
if (query.isBlank()) {
return flowOf(persistentListOf())
}
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Location)
return combineTransform(settings.data, hasPermission) { settingsData, permission ->
emit(persistentListOf())
if (!permission || settingsData.providers.isEmpty()) {
return@combineTransform
}
val userLocation = poseProvider.getLocation().firstOrNull()
?: poseProvider.lastLocation
?: return@combineTransform
val providers = settingsData.providers.map {
when (it) {
"openstreetmaps" -> OsmLocationProvider(context, settings)
else -> PluginLocationProvider(context, it)
}
}
supervisorScope {
val result = MutableStateFlow(persistentListOf<Location>())
for (provider in providers) {
launch {
val r = provider.search(
query,
userLocation,
allowNetwork,
settingsData.searchRadius,
settingsData.hideUncategorized
)
result.update {
(it + r).toPersistentList()
}
}
}
emitAll(result)
}
}
}
}
@@ -0,0 +1,19 @@
package de.mm20.launcher2.locations
import de.mm20.launcher2.locations.providers.PluginLocation
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocation
import de.mm20.launcher2.locations.providers.openstreetmaps.OsmLocationProvider
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository
import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named
import org.koin.dsl.module
val locationsModule = module {
single<OsmLocationProvider> { OsmLocationProvider(androidContext(), get()) }
single<LocationsRepository> { LocationsRepository(androidContext(), get(), get(), get()) }
factory<SearchableRepository<Location>>(named<Location>()) { get<LocationsRepository>() }
factory<SearchableDeserializer>(named(OsmLocation.DOMAIN)) { OsmLocationDeserializer(get()) }
factory<SearchableDeserializer>(named(PluginLocation.DOMAIN)) { PluginLocationDeserializer(androidContext(), get()) }
}
@@ -0,0 +1,16 @@
package de.mm20.launcher2.locations.providers
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.UpdateResult
internal typealias AndroidLocation = android.location.Location
internal interface LocationProvider<TId> {
suspend fun search(
query: String,
userLocation: AndroidLocation,
allowNetwork: Boolean,
searchRadiusMeters: Int,
hideUncategorized: Boolean
): List<Location>
}
@@ -0,0 +1,66 @@
package de.mm20.launcher2.locations.providers
import android.content.Context
import android.graphics.drawable.Drawable
import de.mm20.launcher2.locations.PluginLocationSerializer
import de.mm20.launcher2.plugin.config.StorageStrategy
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.Attribution
import de.mm20.launcher2.search.location.Departure
import de.mm20.launcher2.search.location.LocationIcon
import de.mm20.launcher2.search.location.OpeningSchedule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class PluginLocation(
override val latitude: Double,
override val longitude: Double,
override val fixMeUrl: String?,
override val icon: LocationIcon?,
override val category: String?,
override val address: Address?,
override val openingSchedule: OpeningSchedule?,
override val websiteUrl: String?,
override val phoneNumber: String?,
override val emailAddress: String?,
override val userRating: Float?,
override val userRatingCount: Int?,
override val departures: List<Departure>?,
override val label: String,
override val timestamp: Long,
override val attribution: Attribution?,
override val updatedSelf: (suspend (SavableSearchable) -> UpdateResult<Location>)?,
override val labelOverride: String? = null,
val authority: String,
val id: String,
val storageStrategy: StorageStrategy,
) : Location, UpdatableSearchable<Location> {
override val key: String
get() = "$domain://$authority:$id"
override fun overrideLabel(label: String): PluginLocation {
return this.copy(labelOverride = label)
}
override val domain: String = DOMAIN
override fun getSerializer(): SearchableSerializer {
return PluginLocationSerializer()
}
override suspend fun getProviderIcon(context: Context): Drawable? {
return withContext(Dispatchers.IO) {
context.packageManager.resolveContentProvider(authority, 0)
?.loadIcon(context.packageManager)
}
}
companion object {
const val DOMAIN = "plugin.location"
}
}
@@ -0,0 +1,121 @@
package de.mm20.launcher2.locations.providers
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.util.Log
import de.mm20.launcher2.plugin.QueryPluginApi
import de.mm20.launcher2.plugin.contracts.LocationPluginContract
import de.mm20.launcher2.plugin.contracts.LocationPluginContract.LocationColumns
import de.mm20.launcher2.plugin.contracts.SearchPluginContract
import de.mm20.launcher2.plugin.data.set
import de.mm20.launcher2.plugin.data.withColumns
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.asUpdateResult
internal class PluginLocationProvider(
context: Context,
private val pluginAuthority: String
) : QueryPluginApi<Triple<String, AndroidLocation, Int>, PluginLocation>(
context,
pluginAuthority
), LocationProvider<String> {
override suspend fun search(
query: String,
userLocation: AndroidLocation,
allowNetwork: Boolean,
searchRadiusMeters: Int,
hideUncategorized: Boolean
): List<Location> {
return search(
query = Triple(query, userLocation, searchRadiusMeters),
allowNetwork = allowNetwork,
)
}
override fun Uri.Builder.appendQueryParameters(query: Triple<String, AndroidLocation, Int>): Uri.Builder {
return apply {
appendQueryParameter(SearchPluginContract.Params.Query, query.first)
appendQueryParameter(
LocationPluginContract.Params.UserLatitude,
query.second.latitude.toString()
)
appendQueryParameter(
LocationPluginContract.Params.UserLongitude,
query.second.longitude.toString()
)
appendQueryParameter(LocationPluginContract.Params.SearchRadius, query.third.toString())
}
}
override fun Cursor.getData(): List<PluginLocation>? {
val config = getConfig()
val cursor = this
if (config == null) {
Log.e("MM20", "Plugin ${pluginAuthority} returned null config")
cursor.close()
return null
}
val results = mutableListOf<PluginLocation>()
val timestamp = System.currentTimeMillis()
cursor.withColumns(LocationColumns) {
while (cursor.moveToNext()) {
val id = cursor[LocationColumns.Id] ?: continue
results.add(
PluginLocation(
id = id,
label = cursor[LocationColumns.Label] ?: continue,
latitude = cursor[LocationColumns.Latitude] ?: continue,
longitude = cursor[LocationColumns.Longitude] ?: continue,
fixMeUrl = cursor[LocationColumns.FixMeUrl],
icon = cursor[LocationColumns.Icon],
category = cursor[LocationColumns.Category],
address = cursor[LocationColumns.Address],
openingSchedule = cursor[LocationColumns.OpeningSchedule],
websiteUrl = cursor[LocationColumns.WebsiteUrl],
phoneNumber = cursor[LocationColumns.PhoneNumber],
emailAddress = cursor[LocationColumns.EmailAddress],
userRating = cursor[LocationColumns.UserRating],
userRatingCount = cursor[LocationColumns.UserRatingCount],
departures = cursor[LocationColumns.Departures],
attribution = cursor[LocationColumns.Attribution],
authority = pluginAuthority,
updatedSelf = {
if (it !is PluginLocation) UpdateResult.TemporarilyUnavailable()
else refresh(it, timestamp).asUpdateResult()
},
timestamp = timestamp,
storageStrategy = config.storageStrategy,
)
)
}
}
return results
}
override fun PluginLocation.toBundle(): Bundle {
return Bundle().apply {
set(LocationColumns.Id, id)
set(LocationColumns.Label, label)
set(LocationColumns.Latitude, latitude)
set(LocationColumns.Longitude, longitude)
set(LocationColumns.FixMeUrl, fixMeUrl)
set(LocationColumns.Icon, icon)
set(LocationColumns.Category, category)
set(LocationColumns.Address, address)
set(LocationColumns.OpeningSchedule, openingSchedule)
set(LocationColumns.WebsiteUrl, websiteUrl)
set(LocationColumns.PhoneNumber, phoneNumber)
set(LocationColumns.EmailAddress, emailAddress)
set(LocationColumns.UserRating, userRating)
set(LocationColumns.UserRatingCount, userRatingCount)
set(LocationColumns.Departures, departures)
set(LocationColumns.Attribution, attribution)
}
}
}
@@ -0,0 +1,495 @@
package de.mm20.launcher2.locations.providers.openstreetmaps
import android.content.Context
import android.util.Log
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 kotlinx.collections.immutable.toImmutableList
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.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.ResolverStyle
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?
) : 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 }
)
}
}
}
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.Candle)
"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)
"breakfast" with (R.string.poi_category_breakfast_restaurant to LocationIcon.Breakfast)
} ?: (R.string.poi_category_restaurant to LocationIcon.Restaurant)
"fuel" -> R.string.poi_category_fuel to LocationIcon.GasStation
"car_rental", "car_sharing" -> R.string.poi_category_car 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)
"golf" with (R.string.poi_category_golf to LocationIcon.Golf)
"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)
}
"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 ?: (R.string.poi_category_other to null)
return context.resources.getString(rid) to icon
}
// allow for 24:00 to be part of the same day
// https://stackoverflow.com/a/31113244
private val DATE_TIME_FORMATTER =
DateTimeFormatter.ISO_LOCAL_TIME.withResolverStyle(ResolverStyle.SMART)
private val timeRegex by lazy {
Regex(
"""^(?:\d{2}:\d{2}-?){2}$""",
RegexOption.IGNORE_CASE
)
}
private val singleDayRegex by lazy {
Regex(
"""^[mtwfsp][ouehra]$""",
RegexOption.IGNORE_CASE
)
}
private val dayRangeRegex by lazy {
Regex(
"""^[mtwfsp][ouehra]-[mtwfsp][ouehra]$""",
RegexOption.IGNORE_CASE
)
}
private val daysOfWeek = enumValues<DayOfWeek>().toList().toImmutableList()
private val twentyFourSeven = daysOfWeek.map {
OpeningHours(
dayOfWeek = it,
startTime = LocalTime.MIDNIGHT,
duration = Duration.ofDays(1)
)
}.toImmutableList()
// If this is not sufficient, resort to implementing https://wiki.openstreetmap.org/wiki/Key:opening_hours/specification
// or port https://github.com/opening-hours/opening_hours.js
internal fun parseOpeningSchedule(it: String?): OpeningSchedule? {
if (it.isNullOrBlank()) return null
val openingHours = mutableListOf<OpeningHours>()
// e.g.
// "Mo-Sa 11:00-14:00, 17:00-23:00; Su 11:00-23:00"
// "Mo-Sa 11:00-21:00; PH,Su off"
// "Mo-Th 10:00-24:00, Fr,Sa 10:00-05:00, PH,Su 12:00-22:00"
var blocks =
it.split(',', ';', ' ').mapNotNull { if (it.isBlank()) null else it.trim() }
if (blocks.first() == "24/7")
return OpeningSchedule.TwentyFourSeven
fun dayOfWeekFromString(it: String): DayOfWeek? = when (it.lowercase()) {
"mo" -> DayOfWeek.MONDAY
"tu" -> DayOfWeek.TUESDAY
"we" -> DayOfWeek.WEDNESDAY
"th" -> DayOfWeek.THURSDAY
"fr" -> DayOfWeek.FRIDAY
"sa" -> DayOfWeek.SATURDAY
"su" -> DayOfWeek.SUNDAY
else -> null
}
var allDay = false
var everyDay = false
fun parseGroup(group: List<String>) {
if (group.isEmpty())
return
var times = group
.filter { timeRegex.matches(it) }
.mapNotNull {
try {
val startTime =
LocalTime.parse(it.substringBefore('-'), DATE_TIME_FORMATTER)
val endTime =
LocalTime.parse(it.substringAfter('-'), DATE_TIME_FORMATTER)
var duration = Duration.between(startTime, endTime)
if (duration.isNegative || duration.isZero)
duration += Duration.ofDays(1)
startTime to duration
} catch (dtpe: DateTimeParseException) {
Log.e(
"OpeningTimeFromOverpassElement",
"Failed to parse opening time $it",
dtpe
)
null
}
}
var days = group
.filter { dayRangeRegex.matches(it) }
.flatMap {
val dowStart = dayOfWeekFromString(it.substringBefore('-'))
?: return@flatMap emptyList()
val dowEnd = dayOfWeekFromString(it.substringAfter('-'))
?: return@flatMap emptyList()
if (dowStart.ordinal <= dowEnd.ordinal)
daysOfWeek.subList(dowStart.ordinal, dowEnd.ordinal + 1)
else // "We-Mo"
daysOfWeek.subList(dowStart.ordinal, daysOfWeek.size)
.union(daysOfWeek.subList(0, dowEnd.ordinal + 1))
}.union(
group.filter { singleDayRegex.matches(it) }
.mapNotNull { dayOfWeekFromString(it) }
)
// if no time specified, treat as "all day"
if (times.isEmpty()) {
allDay = true
times = listOf(LocalTime.MIDNIGHT to Duration.ofDays(1))
}
// if no day specified, treat as "every day"
if (days.isEmpty()) {
if (group.any { it.equals("PH", ignoreCase = true) }) {
times = emptyList()
} else {
everyDay = true
days = daysOfWeek.toSet()
}
}
openingHours.addAll(days.flatMap { day ->
times.map { (start, duration) ->
OpeningHours(
dayOfWeek = day,
startTime = start,
duration = duration
)
}
})
}
while (true) {
if (blocks.isEmpty())
break
// assuming that there are blocks that only contain time
// treating them as "every day of the week"
if (blocks.size < 2) {
parseGroup(blocks)
break
}
val nextTimeIndex =
blocks.indexOfFirst { timeRegex.matches(it) }
// no time left, so probably no sensible information
// willingly skips "off" and "closed" as they are not useful
if (nextTimeIndex == -1)
break
// assuming next block to start with the first date coming after a time block
var nextGroupIndex =
blocks.subList(nextTimeIndex, blocks.size)
.indexOfFirst { !timeRegex.matches(it) }
// no day left, so we are done
if (nextGroupIndex == -1) {
parseGroup(blocks)
break
}
// convert index from sublist context
nextGroupIndex += nextTimeIndex
parseGroup(blocks.subList(0, nextGroupIndex))
blocks = blocks.subList(nextGroupIndex, blocks.size)
}
return if (allDay && everyDay) {
OpeningSchedule.TwentyFourSeven
} else {
OpeningSchedule.Hours(openingHours)
}
}
@@ -0,0 +1,171 @@
package de.mm20.launcher2.locations.providers.openstreetmaps
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.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.Location
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
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()
}
suspend fun searchByTag(tag: String): OverpassResponse? =
overpassApi.first()?.runCatching {
this.search(
OverpassFuzzyRadiusQuery(
tag = tag,
query = 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)
}
.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(7)
.toImmutableList()
}
}
@@ -0,0 +1,100 @@
package de.mm20.launcher2.locations.providers.openstreetmaps
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
data class OverpassFuzzyRadiusQuery(
val tag: String = "name",
val query: String,
val radius: Int,
val latitude: Double,
val longitude: Double,
val caseInvariant: Boolean = true,
)
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 {
// 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(' ')
.joinToString(
separator = ".*",
prefix = "\"",
postfix = "\""
) { Regex.escapeReplacement(it) }
val overpassQlBuilder = StringBuilder()
overpassQlBuilder.append("[out:json];")
// nw: node or way
overpassQlBuilder.append("nw(around:", value.radius, ',', value.latitude, ',', value.longitude, ')')
overpassQlBuilder.append('[', value.tag, '~', escapedQueryName, if (value.caseInvariant) ",i];" else "];")
// 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
}
}