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:
@@ -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")
|
||||
|
||||
+6
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-keep class de.mm20.launcher2.openstreetmaps.** { *; }
|
||||
-keep class kotlin.coroutines.Continuation
|
||||
+21
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
Reference in New Issue
Block a user