Add OSM search provider (#611)

* add openstreetmaps module in data

* fix injection

* retrofit2 implementation

* tokenization

* partial rewrite of OpeningTime.fromOverpassElement

* finish rewrite of OpeningTime.fromOverpassElement

* fix merge x)

* configurable search radius

* add settings section and disable setting explicit timeout values for http-client to alleviate issues during debugging

* settings screen localization

* enable radius slider only when locations are enabled

* fix dayRange parsing, add barebones UI

* add files to git

* add location listener in SearchableItemVM that gets activated by LocationItem

* add heading listener

* Calculations, UI additions

* rename settings to LocationsSettings

* use android location library for bearing calculations

* location fix

* rotation fix, demo UI

* working buttons for launching map and website (if available)

* finish botched UI

* improve overpass query by utilizing regex for fuzzy search results

* add link to documentation for further reference

* localization comments

* remove wikipedia minification setting

* schema version, default radius 1.5km

* move osm-specific opening-time parsing to OsmLocation

* refactor with callbackFlow

* remember flow and set minimum distance update to 1m

* refactor for replacementIcon, add imperial unit option

* 'open until' UI fix

* implement serializers

* catch errors in deserializer

* hacky live sorting by distance

* give max priority to bestmatch determined by SearchVM

* add yards as additional step to metersToLocalizzedString

* move http-client from serializers of osmlocation to companion object for cache updating

* add setting for custom URL

* round yards to int

* add botched map preview

* unbotch map tiles, draw user location in map (proof of concept)

* - create MapTiles Composable
- add border around map
- add indicators for location and userlocation, when on map

* fix default imperial units setting

* fix tint color

* add OSM attribution string

* display loading animation when tiles can't be shown yet

* create compose preview of maptiles

* UI work

* being glad that API's just return null instead of throwing information

* tryStartActivity

* aniimate card row placement

* Text alignment, padding

* Rotation -PI/PI wrap fix

* fix direction arrow rotation when screen is upside down

* more icons

* icons, settings, localization
- consider other tags than "amenity" when determining location category
- add many more location categories with corresponding icons
- add settings to disable map theming and hide search results with
  LocationCategory.OTHER
- add default localizations for settings

* catch errors when deserializing location category

* move location and heading functions to Context.kt in extensions

* fix hideUncategorized criterion

* add pre-sorting by distance for location results in SearchVM by injecting Context into search()

* specify receiver parameters in ktx.Context lambdas

* move pose logic and context dependency to new module devicepose with DevicePoseProvider

* git, add the frickin' module

* search overpass for nodes and ways
include category for parcel_locker
already start searching for queries extending length 2

* make openingTimes immutable

* OsmRepository changes
- include telephone number
- don't try to repeatedly update cache if there is no value to be
  updated to
- deduplicate results with same label by category and distance (100m)
- include fixmeurl to point to openstreetmaps.org/fixthemap

* ask for center in overpass API to compute center coordinates of ways

* search for brand

* add chemist location category

* restaurant / fastfood icon shenanigans

* actually add the icons :|

* add leisure tag for leisure:fitness_centre

* return to 'open until'/'opens in'/'open next'

* adding missing UI features
- bug report dialog
- call button if phone number exists
- grid item popup

* refactor to handle 24/7 locations more comfortably

* hide hours in 'opens_in' when they are zero

* show maptiles such that user is always in view

* drawing adjustments

* cache previous zoom level to speed up tile coordinate calculations

* using remember

* using MutableIntState

* fix logic that determines whether tiles are loading

* fix for numTiles == 9

* one plus one is two plus one makes three quick maths

* animate user location indicator, remember calculations

* fix off by one when determining next opening hour

* second attempt to fix upside down arrow rotation (probably fine now)

* logging

* reconsider declination, inject samplingPeriod

* undoing the merge undo

* move localization string to i18n

* revert reordering by distance

* refactor .distanceTo

* make Location abstract class to override compareTo with cached distance to correct sort order in search results

* when it is if when you could use when

* replace Pair with dataclass

* condition check order

* not creating objects with undefined locations, removing suspend from getCategory()

* inject permissionsmanager as constructor parameter

* Store OSM settings in decentralized datastore

* Update searchable content in database on launch

* Refactor, add mechanism to load updated searchable data lazily

* Cache all OSM data in launcher database

* Add pin to favorites button to location results

* Add sealed class UpdateResult
that is returned by awaiting updatedSelf of DeferredSearchable

- update on success
- set flag on temporarily unavailable (TODO add some UI indication)
- delete and invalidate VM on permanently unavailable (Display some
  message window to user?)

* Move sorting of Locations from OsmRepository
to SearchVM using cached location in DevicePoseProvider, if available

* make use of cached location in code

* make use of DevicePoseProvider in WeatherRepository

* inject via koin

* increase getLocation().timeout() to 10 minutes since we are asking for locations only every hour, so 10 minutes seem reasonable (?)

* poll new location every time

* add icon for cached results where results are temporarily unavailable

* Refactor DeferredSearchable to UpdatableSearchable
that receives a closure to retrieve an updated self.

- moved timestamp (formerly `updatedAt`) to UpdatableSearchable
- moved logic whether to update searchable to `requestUpdatedSearchable`
  in SearchableItemVM, which gets triggered every time the details of
  the item are shown
- keep track in SearchableVM whether we should retry updating, possibly
  bypassing a timestamp value that is not old enough
- show toast upon permanently unavailable
- animate "cached_searchable" icon
- make "cached_searchable" icon clickable to show toast explaining the
  situation

* logging on PermanentlyUnavailable

* refactor OsmRepository.update()

* MapTheming adjustments. There is now darkmode, hooray!

* remove outdated comment

* code tidying

* remove unnecessary LaunchedEffect

* make outdated badge only clickable when actually outdated

* deserialize opening schedule

* set deserialized props to null if strings are blank

* also consider contact:* tagging scheme for website & phone

* tweaks

* git add Result.kt

* don't search for locations if network is not allowed

* merge fixes

* Move location search settings to preferences module

* Change wording and order of location search preferences

* Limit location search results

* Order location search results by distance

* Use a sequence

* Android Studio's suggestion wasn't as fleshed out as one would hope

* Add proguard rules

* Rename TileMapRepository to MapTileLoader

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph
2024-03-26 19:06:19 +01:00
committed by GitHub
co-authored by MM20
parent 4b30b13266
commit cba387c832
133 changed files with 4123 additions and 79 deletions
@@ -1,5 +1,6 @@
package de.mm20.launcher2.database
import android.util.Log
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
@@ -8,13 +9,14 @@ import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import de.mm20.launcher2.database.entities.SavedSearchableEntity
import de.mm20.launcher2.database.entities.SavedSearchableUpdateContentEntity
import de.mm20.launcher2.database.entities.SavedSearchableUpdatePinEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SearchableDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(searchable: SavedSearchableEntity)
suspend fun insert(searchable: SavedSearchableEntity): Long
@Upsert(entity = SavedSearchableEntity::class)
suspend fun upsert(searchable: SavedSearchableEntity)
@@ -25,6 +27,9 @@ interface SearchableDao {
@Update(entity = SavedSearchableEntity::class)
suspend fun update(searchable: SavedSearchableUpdatePinEntity)
@Update(entity = SavedSearchableEntity::class)
suspend fun update(searchable: SavedSearchableUpdateContentEntity)
@Query(
"SELECT * FROM Searchable " +
"WHERE (" +
@@ -146,7 +151,15 @@ interface SearchableDao {
incrementLaunchCount(item.key)
increaseWeightWhere(item.key, alpha)
reduceWeightExcept(item.key, alpha)
insert(item)
if (insert(item) == -1L) {
update(
SavedSearchableUpdateContentEntity(
serializedSearchable = item.serializedSearchable,
type = item.type,
key = item.key,
)
)
}
}
@Query("UPDATE Searchable SET launchCount = launchCount + 1 WHERE `key` = :key")
@@ -20,4 +20,10 @@ data class SavedSearchableUpdatePinEntity(
val type: String,
@ColumnInfo(name = "searchable") val serializedSearchable: String,
val pinPosition: Int? = null,
)
data class SavedSearchableUpdateContentEntity(
val key: String,
val type: String,
@ColumnInfo(name = "searchable") val serializedSearchable: String,
)
+1
View File
@@ -0,0 +1 @@
/build
+59
View File
@@ -0,0 +1,59 @@
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
}
android {
namespace = "de.mm20.launcher2.openstreetmaps"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.browser)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.okhttp)
implementation(libs.bundles.retrofit)
implementation(libs.koin.android)
implementation(libs.androidx.appcompat)
implementation(project(":core:preferences"))
implementation(project(":core:base"))
implementation(project(":core:ktx"))
implementation(project(":core:permissions"))
implementation(project(":core:crashreporter"))
implementation(project(":core:devicepose"))
}
+2
View File
@@ -0,0 +1,2 @@
-keep class de.mm20.launcher2.openstreetmaps.** { *; }
-keep class kotlin.coroutines.Continuation
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -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,13 @@
package de.mm20.launcher2.openstreetmaps
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableRepository
import org.koin.core.qualifier.named
import org.koin.dsl.module
val openStreetMapsModule = module {
single<OsmRepository> { OsmRepository(get(), get(), get()) }
factory<SearchableRepository<Location>>(named<Location>()) { get<OsmRepository>() }
factory<SearchableDeserializer>(named(OsmLocation.DOMAIN)) { OsmLocationDeserializer(get()) }
}
@@ -0,0 +1,297 @@
package de.mm20.launcher2.openstreetmaps
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.net.Uri
import android.util.Log
import de.mm20.launcher2.ktx.orRunCatching
import de.mm20.launcher2.ktx.tryStartActivity
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.LocationCategory
import de.mm20.launcher2.search.OpeningHours
import de.mm20.launcher2.search.OpeningSchedule
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.UpdateResult
import de.mm20.launcher2.search.UpdatableSearchable
import kotlinx.collections.immutable.toImmutableList
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
internal data class OsmLocation(
internal val id: Long,
override val label: String,
override val category: LocationCategory?,
override val latitude: Double,
override val longitude: Double,
override val street: String?,
override val houseNumber: String?,
override val openingSchedule: OpeningSchedule?,
override val websiteUrl: String?,
override val phoneNumber: String?,
override val labelOverride: String? = null,
override val timestamp: Long,
override var updatedSelf: (suspend () -> UpdateResult<Location>)? = null,
) : Location, UpdatableSearchable<Location> {
override val domain: String
get() = DOMAIN
override val key: String = "$domain://$id"
override val fixMeUrl: String
get() = FIXMEURL
override fun overrideLabel(label: String): OsmLocation {
return this.copy(labelOverride = label)
}
override fun launch(context: Context, options: Bundle?): Boolean {
return context.tryStartActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse("geo:$latitude,$longitude?q=${Uri.encode(label)}")
),
options
)
}
override fun getSerializer(): SearchableSerializer {
return OsmLocationSerializer()
}
companion object {
internal const val DOMAIN = "osm"
internal const val FIXMEURL = "https://www.openstreetmap.org/fixthemap"
private val categoryTags = setOf(
"amenity",
"shop",
"sport", // "sport:soccer"
"railway", // "railway:stop"
"highway", // "highway:bus_stop"
"tourism", // "tourism:museum"
"leisure", // "leisure:fitness_center"
)
fun fromOverpassResponse(
result: OverpassResponse
): List<OsmLocation> = result.elements.mapNotNull {
OsmLocation(
id = it.id,
label = it.tags["name"] ?: it.tags["brand"] ?: return@mapNotNull null,
category = it.tags.firstNotNullOfOrNull { (tag, value) ->
if (tag.lowercase() in categoryTags) {
value
.split(' ', ',', '.', ';') // in case there are multiple
.firstNotNullOfOrNull { value ->
runCatching {
LocationCategory.valueOf(value.uppercase(Locale.ROOT))
}.orRunCatching {
LocationCategory.valueOf(
// e.g. "railway:stop" -> "RAILWAY_STOP"
"${tag}_${value}".uppercase(
Locale.ROOT
)
)
}.getOrNull()
}
} else null
} ?: LocationCategory.OTHER,
latitude = it.lat ?: it.center?.lat ?: return@mapNotNull null,
longitude = it.lon ?: it.center?.lon ?: return@mapNotNull null,
street = it.tags["addr:street"],
houseNumber = it.tags["addr:housenumber"],
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"],
timestamp = System.currentTimeMillis(),
)
}
}
}
// 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(
isTwentyFourSeven = true,
openingHours = 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()) {
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 OpeningSchedule(
isTwentyFourSeven = allDay && everyDay,
openingHours.toImmutableList()
)
}
@@ -0,0 +1,195 @@
package de.mm20.launcher2.openstreetmaps
import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.devicepose.DevicePoseProvider
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.LocationCategory
import de.mm20.launcher2.search.SearchableRepository
import de.mm20.launcher2.search.UpdateResult
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
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.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
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
internal class OsmRepository(
private val settings: LocationSearchSettings,
private val poseProvider: DevicePoseProvider,
permissionsManager: PermissionsManager,
) : SearchableRepository<Location> {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val httpClient = OkHttpClient()
private val overpassService = 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)
private val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location)
internal suspend fun update(
id: Long
): UpdateResult<Location> = overpassService.first()?.runCatching {
this.search(
OverpassIdQuery(
id = id
)
).let {
OsmLocation.fromOverpassResponse(it)
}.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("OsmLocationDeserializer", "overpassService was not initialized")
UpdateResult.TemporarilyUnavailable()
}
override fun search(query: String, allowNetwork: Boolean): Flow<ImmutableList<Location>> = channelFlow {
send(persistentListOf())
if (!allowNetwork) return@channelFlow
// values higher than 2 might block searches for "dm"
// (Drogerie Markt, a problem specific to germany, but probably also relevant for other countries)
if (query.length < 2) return@channelFlow
hasLocationPermission.collectLatest { locationPermission ->
if (!locationPermission) return@collectLatest
settings.data.collectLatest dataStore@{ settings ->
if (!settings.enabled) return@dataStore
val userLocation =
poseProvider.getLocation().firstOrNull() ?: poseProvider.lastLocation
?: return@dataStore
withContext(Dispatchers.IO) {
httpClient.dispatcher.cancelAll()
}
suspend fun searchByTag(tag: String): OverpassResponse? =
overpassService.first()?.runCatching {
this.search(
OverpassFuzzyRadiusQuery(
tag = tag,
query = query,
radius = settings.searchRadius,
latitude = userLocation.latitude,
longitude = userLocation.longitude,
)
)
}?.onFailure {
if (it !is HttpException && it !is CancellationException) {
Log.e("OsmRepository", "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)
async(this.coroutineContext) { searchByTag("name") },
async(this.coroutineContext) { searchByTag("brand") },
).flatMap {
it?.let {
OsmLocation.fromOverpassResponse(it)
} ?: emptyList()
}
if (result.isNotEmpty()) {
send(
result
.asSequence()
.filter {
!settings.hideUncategorized || (it.category != null && it.category != LocationCategory.OTHER)
}
.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,107 @@
package de.mm20.launcher2.openstreetmaps
import de.mm20.launcher2.ktx.jsonObjectOf
import de.mm20.launcher2.search.LocationCategory
import de.mm20.launcher2.search.OpeningHours
import de.mm20.launcher2.search.OpeningSchedule
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import org.json.JSONArray
import org.json.JSONObject
import java.time.DayOfWeek
import java.time.Duration
import java.time.LocalTime
class OsmLocationSerializer : SearchableSerializer {
override fun serialize(searchable: SavableSearchable): String {
searchable as OsmLocation
return jsonObjectOf(
"id" to searchable.id,
"lat" to searchable.latitude,
"lon" to searchable.longitude,
"category" to searchable.category?.name,
"label" to searchable.label,
"street" to searchable.street,
"houseNumber" to searchable.houseNumber,
"websiteUrl" to searchable.websiteUrl,
"phoneNumber" to searchable.phoneNumber,
"openingSchedule" to searchable.openingSchedule?.let {
jsonObjectOf(
"isTwentyFourSeven" to it.isTwentyFourSeven,
"openingHours" to JSONArray(it.openingHours.map {
jsonObjectOf(
"day" to it.dayOfWeek.value,
"openingTime" to it.startTime.toSecondOfDay() * 1000L,
"duration" to it.duration.toMillis(),
)
})
)
},
"timestamp" to searchable.timestamp,
).toString()
}
override val typePrefix: String
get() = "osmlocation"
}
internal class OsmLocationDeserializer(
private val osmRepository: OsmRepository,
) : SearchableDeserializer {
override suspend fun deserialize(serialized: String): SavableSearchable {
val json = JSONObject(serialized)
val id = json.getLong("id")
return OsmLocation(
id = id,
latitude = json.getDouble("lat"),
longitude = json.getDouble("lon"),
category = json.getString("category").runCatching { LocationCategory.valueOf(this) }
.getOrNull(),
label = json.getString("label"),
street = json.optString("street").takeIf { it.isNotBlank() },
houseNumber = json.optString("houseNumber").takeIf { it.isNotBlank() },
openingSchedule = json.optJSONObject("openingSchedule")?.let { getOpeningSchedule(it) },
websiteUrl = json.optString("websiteUrl").takeIf { it.isNotBlank() },
phoneNumber = json.optString("phoneNumber").takeIf { it.isNotBlank() },
timestamp = json.optLong("timestamp"),
updatedSelf = { osmRepository.update(id) }
)
}
private fun getOpeningSchedule(json: JSONObject): OpeningSchedule {
return OpeningSchedule(
isTwentyFourSeven = json.optBoolean("isTwentyFourSeven"),
openingHours = json.optJSONArray("openingHours")?.let {
getOpeningHours(it)
} ?: persistentListOf()
)
}
private fun getOpeningHours(array: JSONArray): ImmutableList<OpeningHours> {
val hours = mutableListOf<OpeningHours>()
for (i in 0 until array.length()) {
val json = array.getJSONObject(i)
val dayOfWeek =
DayOfWeek.of(json.optInt("day").takeIf { it in 1..7 } ?: continue)
val openingTimeMillis =
json.optLong("openingTime", -1).takeIf { it >= 0 } ?: continue
val durationMillis = json.optLong("duration", -1).takeIf { it >= 0 } ?: continue
hours.add(
OpeningHours(
dayOfWeek = dayOfWeek,
startTime = LocalTime.ofSecondOfDay(openingTimeMillis / 1000L),
duration = Duration.ofMillis(durationMillis)
)
)
}
return hours.toPersistentList()
}
}
@@ -0,0 +1,100 @@
package de.mm20.launcher2.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
}
}
+1
View File
@@ -51,5 +51,6 @@ dependencies {
implementation(project(":core:preferences"))
implementation(project(":core:permissions"))
implementation(project(":core:i18n"))
implementation(project(":core:devicepose"))
}
@@ -1,14 +1,11 @@
package de.mm20.launcher2.weather
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.util.Log
import androidx.core.content.getSystemService
import androidx.work.*
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.devicepose.DevicePoseProvider
import de.mm20.launcher2.ktx.or
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.PluginRepository
@@ -24,8 +21,9 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.time.Duration
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.minutes
interface WeatherRepository {
fun getProviders(): Flow<List<WeatherProviderInfo>>
@@ -41,21 +39,20 @@ internal class WeatherRepositoryImpl(
private val context: Context,
private val database: AppDatabase,
private val settings: WeatherSettings,
private val pluginRepository: PluginRepository,
private val pluginRepository: PluginRepository
) : WeatherRepository, KoinComponent {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val permissionsManager: PermissionsManager by inject()
private val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location)
override fun getForecasts(limit: Int?): Flow<List<Forecast>> {
return database.weatherDao().getForecasts(limit ?: 99999)
.map { it.map { Forecast(it) } }
}
override fun getDailyForecasts(): Flow<List<DailyForecast>> {
return database.weatherDao().getForecasts()
.map { it.map { Forecast(it) } }
@@ -73,7 +70,7 @@ internal class WeatherRepositoryImpl(
init {
val weatherRequest =
PeriodicWorkRequest.Builder(WeatherUpdateWorker::class.java, 60, TimeUnit.MINUTES)
PeriodicWorkRequestBuilder<WeatherUpdateWorker>(Duration.ofMinutes(60))
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weather",
@@ -134,7 +131,7 @@ internal class WeatherRepositoryImpl(
private fun requestUpdate() {
val weatherRequest = OneTimeWorkRequest.Builder(WeatherUpdateWorker::class.java)
val weatherRequest = OneTimeWorkRequestBuilder<WeatherUpdateWorker>()
.addTag("weather")
.build()
WorkManager.getInstance(context).enqueue(weatherRequest)
@@ -151,15 +148,35 @@ internal class WeatherRepositoryImpl(
override fun getProviders(): Flow<List<WeatherProviderInfo>> {
val providers = mutableListOf<WeatherProviderInfo>()
providers.add(WeatherProviderInfo(BrightSkyProvider.Id, context.getString(R.string.provider_brightsky)))
providers.add(
WeatherProviderInfo(
BrightSkyProvider.Id,
context.getString(R.string.provider_brightsky)
)
)
if (OpenWeatherMapProvider.isAvailable(context)) {
providers.add(WeatherProviderInfo(OpenWeatherMapProvider.Id, context.getString(R.string.provider_openweathermap)))
providers.add(
WeatherProviderInfo(
OpenWeatherMapProvider.Id,
context.getString(R.string.provider_openweathermap)
)
)
}
if (MetNoProvider.isAvailable(context)) {
providers.add(WeatherProviderInfo(MetNoProvider.Id, context.getString(R.string.provider_metno)))
providers.add(
WeatherProviderInfo(
MetNoProvider.Id,
context.getString(R.string.provider_metno)
)
)
}
if (HereProvider.isAvailable(context)) {
providers.add(WeatherProviderInfo(HereProvider.Id, context.getString(R.string.provider_here)))
providers.add(
WeatherProviderInfo(
HereProvider.Id,
context.getString(R.string.provider_here)
)
)
}
val pluginProviders = pluginRepository.findMany(type = PluginType.Weather, enabled = true)
return pluginProviders.map {
@@ -170,11 +187,14 @@ internal class WeatherRepositoryImpl(
}
}
class WeatherUpdateWorker(val context: Context, params: WorkerParameters) :
CoroutineWorker(context, params), KoinComponent {
class WeatherUpdateWorker(
val context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params), KoinComponent {
private val appDatabase: AppDatabase by inject()
private val settings: WeatherSettings by inject()
private val locationProvider: DevicePoseProvider by inject()
override suspend fun doWork(): Result {
Log.d("WeatherUpdateWorker", "Requesting weather data")
@@ -218,15 +238,9 @@ class WeatherUpdateWorker(val context: Context, params: WorkerParameters) :
}
}
private fun getLastKnownLocation(): LatLon? {
val lm = context.getSystemService<LocationManager>()!!
var location: Location? = null
if (context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
location = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER)
}
if (location == null && context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) {
location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
}
return location?.let { LatLon(it.latitude, it.longitude) }
}
@OptIn(FlowPreview::class)
private suspend fun getLastKnownLocation(): LatLon? =
locationProvider.getLocation().timeout(10.minutes).firstOrNull().or {
locationProvider.lastLocation
}?.let { LatLon(it.latitude, it.longitude) }
}