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:
@@ -24,6 +24,7 @@ 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 kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
@@ -312,8 +313,7 @@ internal class OwncloudFileDeserializer : SearchableDeserializer {
|
||||
}
|
||||
}
|
||||
|
||||
internal class PluginFileSerializer(
|
||||
) : SearchableSerializer {
|
||||
internal class PluginFileSerializer : SearchableSerializer {
|
||||
override fun serialize(searchable: SavableSearchable): String? {
|
||||
searchable as PluginFile
|
||||
if (searchable.storageStrategy == StorageStrategy.StoreReference) {
|
||||
@@ -333,7 +333,8 @@ internal class PluginFileSerializer(
|
||||
"thumbnailUri" to searchable.thumbnailUri?.toString(),
|
||||
"isDirectory" to searchable.isDirectory,
|
||||
"authority" to searchable.authority,
|
||||
"strategy" to if (searchable.storageStrategy == StorageStrategy.StoreCopy) "copy" else "deferred",
|
||||
"timestamp" to searchable.timestamp,
|
||||
"strategy" to "copy",
|
||||
).toString()
|
||||
}
|
||||
}
|
||||
@@ -348,84 +349,46 @@ internal class PluginFileDeserializer(
|
||||
private val pluginRepository: PluginRepository,
|
||||
) : SearchableDeserializer {
|
||||
override suspend fun deserialize(serialized: String): SavableSearchable? {
|
||||
val jsonObject = JSONObject(serialized)
|
||||
val obj = JSONObject(serialized)
|
||||
|
||||
return when (jsonObject.optString("strategy", "copy")) {
|
||||
"ref" -> {
|
||||
getByRef(jsonObject)
|
||||
}
|
||||
|
||||
"deferred" -> {
|
||||
getDeferred(jsonObject)
|
||||
}
|
||||
|
||||
else -> {
|
||||
getByCopy(jsonObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getByRef(obj: JSONObject): File? {
|
||||
val authority = obj.getString("authority")
|
||||
val id = obj.getString("id")
|
||||
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
|
||||
if (!plugin.enabled) return null
|
||||
val provider = PluginFileProvider(context, authority)
|
||||
try {
|
||||
val authority = obj.getString("authority")
|
||||
val id = obj.getString("id")
|
||||
val plugin = pluginRepository.get(authority).firstOrNull() ?: return null
|
||||
if (!plugin.enabled) return null
|
||||
val provider = PluginFileProvider(context, authority)
|
||||
return provider.getFile(id)
|
||||
} catch (e: Exception) {
|
||||
CrashReporter.logException(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
return when (obj.optString("strategy", "copy")) {
|
||||
"ref" -> {
|
||||
provider.get(id).getOrNull()
|
||||
}
|
||||
|
||||
private fun getDeferred(obj: JSONObject): File? {
|
||||
val cached = getByCopy(obj) ?: return null
|
||||
val timestamp = obj.optLong("timestamp", 0L)
|
||||
return DeferredFile(
|
||||
cachedFile = cached as PluginFile,
|
||||
timestamp = timestamp,
|
||||
updatedSelf = {
|
||||
val plugin = pluginRepository.get(cached.authority).firstOrNull()
|
||||
?: return@DeferredFile UpdateResult.PermanentlyUnavailable()
|
||||
if (!plugin.enabled) return@DeferredFile UpdateResult.PermanentlyUnavailable()
|
||||
val provider = PluginFileProvider(context, cached.authority)
|
||||
try {
|
||||
val file = provider.getFile(cached.id)
|
||||
if (file == null) {
|
||||
UpdateResult.PermanentlyUnavailable()
|
||||
} else {
|
||||
UpdateResult.Success(file)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
CrashReporter.logException(e)
|
||||
UpdateResult.TemporarilyUnavailable(e)
|
||||
else -> {
|
||||
val uri = obj.getString("uri")
|
||||
val thumbnailUri = obj.optString("thumbnailUri")
|
||||
val timestamp = obj.optLong("timestamp", 0L)
|
||||
val file = PluginFile(
|
||||
id = obj.getString("id"),
|
||||
path = obj.getString("path"),
|
||||
mimeType = obj.getString("mimeType"),
|
||||
size = obj.optLong("size", 0L),
|
||||
metaData = persistentMapOf(),
|
||||
label = obj.getString("label"),
|
||||
uri = Uri.parse(uri),
|
||||
thumbnailUri = thumbnailUri.takeIf { it.isNotEmpty() }?.let { Uri.parse(it) },
|
||||
storageStrategy = StorageStrategy.StoreCopy,
|
||||
isDirectory = obj.optBoolean("isDirectory", false),
|
||||
authority = obj.getString("authority"),
|
||||
timestamp = timestamp,
|
||||
updatedSelf = {
|
||||
if (it !is PluginFile) UpdateResult.TemporarilyUnavailable()
|
||||
else provider.refresh(it, timestamp).asUpdateResult()
|
||||
}
|
||||
)
|
||||
return file
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getByCopy(obj: JSONObject): File? {
|
||||
try {
|
||||
val uri = obj.getString("uri")
|
||||
val thumbnailUri = obj.optString("thumbnailUri")
|
||||
return PluginFile(
|
||||
id = obj.getString("id"),
|
||||
path = obj.getString("path"),
|
||||
mimeType = obj.getString("mimeType"),
|
||||
size = obj.optLong("size", 0L),
|
||||
metaData = persistentMapOf(),
|
||||
label = obj.getString("label"),
|
||||
uri = Uri.parse(uri),
|
||||
thumbnailUri = thumbnailUri.takeIf { it.isNotEmpty() }?.let { Uri.parse(it) },
|
||||
storageStrategy = StorageStrategy.StoreCopy,
|
||||
isDirectory = obj.optBoolean("isDirectory", false),
|
||||
authority = obj.getString("authority"),
|
||||
)
|
||||
} catch (e: JSONException) {
|
||||
CrashReporter.logException(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package de.mm20.launcher2.files.providers
|
||||
|
||||
import de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.UpdatableSearchable
|
||||
import de.mm20.launcher2.search.UpdateResult
|
||||
|
||||
class DeferredFile(
|
||||
cachedFile: File,
|
||||
override val timestamp: Long,
|
||||
override var updatedSelf: (suspend () -> UpdateResult<File>)? = null,
|
||||
override var updatedSelf: (suspend (SavableSearchable) -> UpdateResult<File>)? = null,
|
||||
) : File by cachedFile, UpdatableSearchable<File>
|
||||
@@ -18,6 +18,8 @@ import de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.search.FileMetaType
|
||||
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 kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -35,7 +37,9 @@ data class PluginFile(
|
||||
val authority: String,
|
||||
internal val storageStrategy: StorageStrategy,
|
||||
override val labelOverride: String? = null,
|
||||
) : File {
|
||||
override val timestamp: Long,
|
||||
override val updatedSelf: (suspend (SavableSearchable) -> UpdateResult<File>)?,
|
||||
) : File, UpdatableSearchable<File> {
|
||||
override val domain: String = Domain
|
||||
|
||||
override val key: String
|
||||
|
||||
+100
-185
@@ -3,100 +3,35 @@ package de.mm20.launcher2.files.providers
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.CancellationSignal
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.util.Log
|
||||
import androidx.core.database.getIntOrNull
|
||||
import androidx.core.database.getLongOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||
import de.mm20.launcher2.plugin.PluginApi
|
||||
import de.mm20.launcher2.plugin.config.SearchPluginConfig
|
||||
import de.mm20.launcher2.plugin.contracts.FilePluginContract
|
||||
import de.mm20.launcher2.plugin.contracts.PluginContract
|
||||
import de.mm20.launcher2.plugin.QueryPluginApi
|
||||
import de.mm20.launcher2.plugin.config.QueryPluginConfig
|
||||
import de.mm20.launcher2.plugin.contracts.FilePluginContract.FileColumns
|
||||
import de.mm20.launcher2.plugin.contracts.SearchPluginContract
|
||||
import de.mm20.launcher2.search.File
|
||||
import de.mm20.launcher2.plugin.data.set
|
||||
import de.mm20.launcher2.plugin.data.withColumns
|
||||
import de.mm20.launcher2.search.FileMetaType
|
||||
import de.mm20.launcher2.search.UpdateResult
|
||||
import de.mm20.launcher2.search.asUpdateResult
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PluginFileProvider(
|
||||
private val context: Context,
|
||||
private val pluginAuthority: String,
|
||||
) : FileProvider {
|
||||
override suspend fun search(query: String, allowNetwork: Boolean): List<File> = withContext(Dispatchers.IO) {
|
||||
val uri = Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(pluginAuthority)
|
||||
.path(SearchPluginContract.Paths.Search)
|
||||
.appendQueryParameter(SearchPluginContract.Paths.QueryParam, query)
|
||||
.appendQueryParameter(SearchPluginContract.Paths.AllowNetworkParam, allowNetwork.toString())
|
||||
.build()
|
||||
val cancellationSignal = CancellationSignal()
|
||||
) : QueryPluginApi<String, PluginFile>(
|
||||
context, pluginAuthority
|
||||
), FileProvider {
|
||||
|
||||
return@withContext suspendCancellableCoroutine {
|
||||
it.invokeOnCancellation {
|
||||
cancellationSignal.cancel()
|
||||
}
|
||||
val cursor = try {
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
null,
|
||||
null,
|
||||
cancellationSignal
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MM20", "Plugin ${pluginAuthority} threw exception")
|
||||
CrashReporter.logException(e)
|
||||
it.resume(emptyList())
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
if (cursor == null) {
|
||||
Log.e("MM20", "Plugin ${pluginAuthority} returned null cursor")
|
||||
it.resume(emptyList())
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
val results = fromCursor(cursor) ?: emptyList()
|
||||
it.resume(results)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPluginConfig(): SearchPluginConfig? {
|
||||
private fun getPluginConfig(): QueryPluginConfig? {
|
||||
return PluginApi(pluginAuthority, context.contentResolver).getSearchPluginConfig()
|
||||
}
|
||||
|
||||
suspend fun getFile(id: String): File? {
|
||||
val uri = Uri.Builder()
|
||||
.scheme("content")
|
||||
.authority(pluginAuthority)
|
||||
.path(SearchPluginContract.Paths.Root)
|
||||
.appendPath(id)
|
||||
.build()
|
||||
val cancellationSignal = CancellationSignal()
|
||||
|
||||
return suspendCancellableCoroutine {
|
||||
it.invokeOnCancellation {
|
||||
cancellationSignal.cancel()
|
||||
}
|
||||
val cursor = context.contentResolver.query(
|
||||
uri,
|
||||
null,
|
||||
null,
|
||||
cancellationSignal
|
||||
) ?: return@suspendCancellableCoroutine it.resume(null)
|
||||
|
||||
val results = fromCursor(cursor)
|
||||
it.resume(results?.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
private fun fromCursor(cursor: Cursor): List<File>? {
|
||||
override fun Cursor.getData(): List<PluginFile>? {
|
||||
val config = getPluginConfig()
|
||||
val cursor = this
|
||||
|
||||
if (config == null) {
|
||||
Log.e("MM20", "Plugin ${pluginAuthority} returned null config")
|
||||
@@ -104,119 +39,99 @@ class PluginFileProvider(
|
||||
return null
|
||||
}
|
||||
|
||||
val idIndex = cursor
|
||||
.getColumnIndex(FilePluginContract.FileColumns.Id)
|
||||
.takeIf { it >= 0 }
|
||||
?: return null
|
||||
val pathIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.Path).takeIf { it >= 0 }
|
||||
val typeIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MimeType).takeIf { it >= 0 }
|
||||
val sizeIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.Size).takeIf { it >= 0 }
|
||||
val nameIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.DisplayName)
|
||||
.takeIf { it >= 0 }
|
||||
?: return null
|
||||
val contentUriIndex = cursor.getColumnIndex(FilePluginContract.FileColumns.ContentUri)
|
||||
.takeIf { it >= 0 }
|
||||
?: return null
|
||||
val thumbnailUriIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.ThumbnailUri)
|
||||
.takeIf { it >= 0 }
|
||||
val directoryIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.IsDirectory).takeIf { it >= 0 }
|
||||
|
||||
val ownerIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.Owner).takeIf { it >= 0 }
|
||||
|
||||
val metaTitleIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaTitle).takeIf { it >= 0 }
|
||||
|
||||
val metaArtistIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaArtist).takeIf { it >= 0 }
|
||||
|
||||
val metaAlbumIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaAlbum).takeIf { it >= 0 }
|
||||
|
||||
val metaDurationIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaDuration).takeIf { it >= 0 }
|
||||
|
||||
val metaYearIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaYear).takeIf { it >= 0 }
|
||||
|
||||
val metaWidthIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaWidth).takeIf { it >= 0 }
|
||||
|
||||
val metaHeightIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaHeight).takeIf { it >= 0 }
|
||||
|
||||
val metaLocationIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaLocation).takeIf { it >= 0 }
|
||||
|
||||
val metaAppNameIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaAppName).takeIf { it >= 0 }
|
||||
|
||||
val metaAppPackageNameIndex =
|
||||
cursor.getColumnIndex(FilePluginContract.FileColumns.MetaAppPackageName)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val results = mutableListOf<File>()
|
||||
while (cursor.moveToNext()) {
|
||||
results.add(
|
||||
PluginFile(
|
||||
id = cursor.getString(idIndex),
|
||||
path = pathIndex?.let { cursor.getString(it) } ?: "",
|
||||
mimeType = typeIndex?.let { cursor.getString(it) }
|
||||
?: "application/octet-stream",
|
||||
size = sizeIndex?.let { cursor.getLong(it) } ?: 0,
|
||||
metaData = buildMap {
|
||||
metaTitleIndex?.let { cursor.getStringOrNull(it) }?.let {
|
||||
put(FileMetaType.Title, it)
|
||||
}
|
||||
metaArtistIndex?.let { cursor.getStringOrNull(it) }?.let {
|
||||
put(FileMetaType.Artist, it)
|
||||
}
|
||||
metaAlbumIndex?.let { cursor.getStringOrNull(it) }?.let {
|
||||
put(FileMetaType.Album, it)
|
||||
}
|
||||
metaDurationIndex?.let { cursor.getLongOrNull(it) }?.let {
|
||||
put(FileMetaType.Duration, DateUtils.formatElapsedTime(it / 1000L))
|
||||
}
|
||||
metaYearIndex?.let { cursor.getIntOrNull(it) }?.let {
|
||||
put(FileMetaType.Year, it.toString())
|
||||
}
|
||||
if (metaWidthIndex != null && metaHeightIndex != null) {
|
||||
val width = cursor.getIntOrNull(metaWidthIndex)
|
||||
val height = cursor.getIntOrNull(metaHeightIndex)
|
||||
val results = mutableListOf<PluginFile>()
|
||||
val timestamp = System.currentTimeMillis()
|
||||
cursor.withColumns(FileColumns) {
|
||||
while (cursor.moveToNext()) {
|
||||
results.add(
|
||||
PluginFile(
|
||||
id = cursor[FileColumns.Id] ?: continue,
|
||||
path = cursor[FileColumns.Path] ?: "",
|
||||
mimeType = cursor[FileColumns.MimeType] ?: "application/octet-stream",
|
||||
size = cursor[FileColumns.Size] ?: 0L,
|
||||
metaData = buildMap {
|
||||
cursor[FileColumns.MetaTitle]?.let {
|
||||
put(FileMetaType.Title, it)
|
||||
}
|
||||
cursor[FileColumns.MetaArtist]?.let {
|
||||
put(FileMetaType.Artist, it)
|
||||
}
|
||||
cursor[FileColumns.MetaAlbum]?.let {
|
||||
put(FileMetaType.Album, it)
|
||||
}
|
||||
cursor[FileColumns.MetaDuration]?.let {
|
||||
put(FileMetaType.Duration, DateUtils.formatElapsedTime(it / 1000L))
|
||||
}
|
||||
cursor[FileColumns.MetaYear]?.let {
|
||||
put(FileMetaType.Year, it.toString())
|
||||
}
|
||||
val width = cursor[FileColumns.MetaWidth]
|
||||
val height = cursor[FileColumns.MetaHeight]
|
||||
if (width != null && height != null) {
|
||||
put(FileMetaType.Dimensions, "${width}x${height}")
|
||||
}
|
||||
cursor[FileColumns.MetaLocation]?.let {
|
||||
put(FileMetaType.Location, it)
|
||||
}
|
||||
cursor[FileColumns.MetaAppName]?.let {
|
||||
put(FileMetaType.AppName, it)
|
||||
}
|
||||
cursor[FileColumns.MetaAppPackageName]?.let {
|
||||
put(FileMetaType.AppPackageName, it)
|
||||
}
|
||||
cursor[FileColumns.Owner]?.let {
|
||||
put(FileMetaType.Owner, it)
|
||||
}
|
||||
}.toPersistentMap(),
|
||||
label = cursor[FileColumns.DisplayName] ?: continue,
|
||||
uri = cursor[FileColumns.DisplayName]?.let { Uri.parse(it) } ?: continue,
|
||||
thumbnailUri = cursor[FileColumns.ThumbnailUri]?.let { Uri.parse(it) },
|
||||
storageStrategy = config.storageStrategy,
|
||||
isDirectory = cursor[FileColumns.IsDirectory] ?: false,
|
||||
authority = pluginAuthority,
|
||||
timestamp = timestamp,
|
||||
updatedSelf = {
|
||||
if (it !is PluginFile) UpdateResult.TemporarilyUnavailable()
|
||||
else refresh(it, timestamp).asUpdateResult()
|
||||
}
|
||||
metaLocationIndex?.let { cursor.getStringOrNull(it) }?.let {
|
||||
put(FileMetaType.Location, it)
|
||||
}
|
||||
metaAppNameIndex?.let { cursor.getStringOrNull(it) }?.let {
|
||||
put(FileMetaType.AppName, it)
|
||||
}
|
||||
metaAppPackageNameIndex?.let { cursor.getStringOrNull(it) }?.let {
|
||||
put(FileMetaType.AppPackageName, it)
|
||||
}
|
||||
ownerIndex?.let { cursor.getStringOrNull(it) }?.let {
|
||||
put(FileMetaType.Owner, it)
|
||||
}
|
||||
}.toPersistentMap(),
|
||||
label = cursor.getString(nameIndex),
|
||||
uri = Uri.parse(cursor.getString(contentUriIndex)),
|
||||
thumbnailUri = thumbnailUriIndex?.let {
|
||||
cursor.getStringOrNull(it)
|
||||
}?.let { Uri.parse(it) },
|
||||
storageStrategy = config.storageStrategy,
|
||||
isDirectory = directoryIndex?.let { cursor.getInt(it) } == 1,
|
||||
authority = pluginAuthority,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
cursor.close()
|
||||
return results
|
||||
}
|
||||
|
||||
override fun PluginFile.toBundle(): Bundle {
|
||||
return Bundle().apply {
|
||||
set(FileColumns.Id, id)
|
||||
set(FileColumns.Path, path)
|
||||
set(FileColumns.MimeType, mimeType)
|
||||
set(FileColumns.Size, size)
|
||||
set(FileColumns.MetaTitle, metaData[FileMetaType.Title])
|
||||
set(FileColumns.MetaArtist, metaData[FileMetaType.Artist])
|
||||
set(FileColumns.MetaAlbum, metaData[FileMetaType.Album])
|
||||
set(FileColumns.MetaDuration, metaData[FileMetaType.Duration]?.toLong())
|
||||
set(FileColumns.MetaYear, metaData[FileMetaType.Year]?.toInt())
|
||||
set(
|
||||
FileColumns.MetaWidth,
|
||||
metaData[FileMetaType.Dimensions]?.split("x")?.getOrNull(0)?.toInt()
|
||||
)
|
||||
set(
|
||||
FileColumns.MetaHeight,
|
||||
metaData[FileMetaType.Dimensions]?.split("x")?.getOrNull(1)?.toInt()
|
||||
)
|
||||
set(FileColumns.MetaLocation, metaData[FileMetaType.Location])
|
||||
set(FileColumns.MetaAppName, metaData[FileMetaType.AppName])
|
||||
set(FileColumns.MetaAppPackageName, metaData[FileMetaType.AppPackageName])
|
||||
set(FileColumns.Owner, metaData[FileMetaType.Owner])
|
||||
set(FileColumns.DisplayName, label)
|
||||
set(FileColumns.ThumbnailUri, thumbnailUri?.toString())
|
||||
set(FileColumns.IsDirectory, isDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
override fun Uri.Builder.appendQueryParameters(query: String): Uri.Builder = apply {
|
||||
appendQueryParameter(SearchPluginContract.Params.Query, query)
|
||||
}
|
||||
}
|
||||
@@ -56,4 +56,5 @@ dependencies {
|
||||
implementation(project(":core:permissions"))
|
||||
implementation(project(":core:crashreporter"))
|
||||
implementation(project(":core:devicepose"))
|
||||
implementation(project(":libs:address-formatter"))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-keep class de.mm20.launcher2.locations.** { *; }
|
||||
-keep class kotlin.coroutines.Continuation
|
||||
@@ -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()) }
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
}
|
||||
+121
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+495
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+171
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package de.mm20.launcher2.openstreetmaps
|
||||
package de.mm20.launcher2.locations.providers.openstreetmaps
|
||||
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -1,2 +0,0 @@
|
||||
-keep class de.mm20.launcher2.openstreetmaps.** { *; }
|
||||
-keep class kotlin.coroutines.Continuation
|
||||
@@ -1,13 +0,0 @@
|
||||
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()) }
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
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 {
|
||||
it.tags ?: return@mapNotNull null
|
||||
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()) {
|
||||
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 OpeningSchedule(
|
||||
isTwentyFourSeven = allDay && everyDay,
|
||||
openingHours.toImmutableList()
|
||||
)
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
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
@@ -1,107 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
+37
-114
@@ -14,6 +14,9 @@ import de.mm20.launcher2.plugin.PluginApi
|
||||
import de.mm20.launcher2.plugin.config.WeatherPluginConfig
|
||||
import de.mm20.launcher2.plugin.contracts.PluginContract
|
||||
import de.mm20.launcher2.plugin.contracts.WeatherPluginContract
|
||||
import de.mm20.launcher2.plugin.contracts.WeatherPluginContract.ForecastColumns
|
||||
import de.mm20.launcher2.plugin.contracts.WeatherPluginContract.LocationColumns
|
||||
import de.mm20.launcher2.plugin.data.withColumns
|
||||
import de.mm20.launcher2.preferences.weather.WeatherLocation
|
||||
import de.mm20.launcher2.weather.Forecast
|
||||
import de.mm20.launcher2.weather.WeatherProvider
|
||||
@@ -107,99 +110,30 @@ internal class PluginWeatherProvider(
|
||||
return cursor.use {
|
||||
val results = mutableListOf<Forecast>()
|
||||
|
||||
val timestampIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Timestamp)
|
||||
.takeIf { it >= 0 } ?: return null
|
||||
val createdAtIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.CreatedAt)
|
||||
.takeIf { it >= 0 } ?: return null
|
||||
val temperatureIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Temperature)
|
||||
.takeIf { it >= 0 } ?: return null
|
||||
val conditionIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Condition)
|
||||
.takeIf { it >= 0 } ?: return null
|
||||
val iconIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Icon).takeIf { it >= 0 }
|
||||
?: return null
|
||||
cursor.withColumns(ForecastColumns) {
|
||||
|
||||
val locationIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Location)
|
||||
.takeIf { it >= 0 }
|
||||
?: return null
|
||||
|
||||
val providerIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Provider)
|
||||
.takeIf { it >= 0 }
|
||||
?: return null
|
||||
|
||||
val providerUrlIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.ProviderUrl)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val precipitationIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Precipitation)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val precipProbabilityIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.RainProbability)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val cloudsIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Clouds)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val humidityIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Humidity)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val windSpeedIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.WindSpeed)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val windDirectionIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.WindDirection)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val pressureIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Pressure)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val nightIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Night)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val minTempIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.TemperatureMin)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
val maxTempIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.TemperatureMax)
|
||||
.takeIf { it >= 0 }
|
||||
|
||||
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
results += Forecast(
|
||||
timestamp = cursor.getLongOrNull(timestampIndex) ?: continue,
|
||||
temperature = cursor.getDoubleOrNull(temperatureIndex) ?: continue,
|
||||
updateTime = cursor.getLongOrNull(createdAtIndex) ?: continue,
|
||||
condition = cursor.getStringOrNull(conditionIndex) ?: continue,
|
||||
icon = getIcon(cursor.getStringOrNull(iconIndex) ?: continue),
|
||||
location = cursor.getStringOrNull(locationIndex) ?: continue,
|
||||
provider = cursor.getStringOrNull(providerIndex) ?: continue,
|
||||
providerUrl = providerUrlIndex?.let { cursor.getStringOrNull(it) } ?: "",
|
||||
clouds = cloudsIndex?.let { cursor.getIntOrNull(it) },
|
||||
humidity = humidityIndex?.let { cursor.getDoubleOrNull(it) },
|
||||
precipitation = precipitationIndex?.let { cursor.getDoubleOrNull(it) },
|
||||
precipProbability = precipProbabilityIndex?.let { cursor.getIntOrNull(it) },
|
||||
windSpeed = windSpeedIndex?.let { cursor.getDoubleOrNull(it) },
|
||||
windDirection = windDirectionIndex?.let { cursor.getDoubleOrNull(it) },
|
||||
pressure = pressureIndex?.let { cursor.getDoubleOrNull(it) },
|
||||
night = nightIndex?.let { cursor.getIntOrNull(it) } == 1,
|
||||
minTemp = minTempIndex?.let { cursor.getDoubleOrNull(it) },
|
||||
maxTemp = maxTempIndex?.let { cursor.getDoubleOrNull(it) },
|
||||
)
|
||||
while (cursor.moveToNext()) {
|
||||
results += Forecast(
|
||||
timestamp = cursor[ForecastColumns.Timestamp] ?: continue,
|
||||
temperature = cursor[ForecastColumns.Temperature] ?: continue,
|
||||
updateTime = cursor[ForecastColumns.CreatedAt] ?: continue,
|
||||
condition = cursor[ForecastColumns.Condition] ?: continue,
|
||||
icon = getIcon(cursor[ForecastColumns.Icon]?.name ?: continue),
|
||||
location = cursor[ForecastColumns.Location] ?: continue,
|
||||
provider = cursor[ForecastColumns.Provider] ?: continue,
|
||||
providerUrl = cursor[ForecastColumns.ProviderUrl] ?: "",
|
||||
clouds = cursor[ForecastColumns.Clouds],
|
||||
humidity = cursor[ForecastColumns.Humidity]?.toDouble(),
|
||||
precipitation = cursor[ForecastColumns.Precipitation],
|
||||
precipProbability = cursor[ForecastColumns.RainProbability],
|
||||
windSpeed = cursor[ForecastColumns.WindSpeed],
|
||||
windDirection = cursor[ForecastColumns.WindDirection],
|
||||
pressure = cursor[ForecastColumns.Pressure],
|
||||
night = cursor[ForecastColumns.Night] ?: false,
|
||||
minTemp = cursor[ForecastColumns.TemperatureMin],
|
||||
maxTemp = cursor[ForecastColumns.TemperatureMax],
|
||||
)
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
@@ -278,29 +212,18 @@ internal class PluginWeatherProvider(
|
||||
return cursor.use {
|
||||
val results = mutableListOf<WeatherLocation>()
|
||||
|
||||
val nameIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.LocationColumns.Name)
|
||||
.takeIf { it >= 0 } ?: return emptyList()
|
||||
val latIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.LocationColumns.Lat)
|
||||
.takeIf { it >= 0 }
|
||||
val lonIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.LocationColumns.Lon)
|
||||
.takeIf { it >= 0 }
|
||||
val locationIdIndex =
|
||||
cursor.getColumnIndex(WeatherPluginContract.LocationColumns.Id)
|
||||
.takeIf { it >= 0 }
|
||||
cursor.withColumns(LocationColumns) {
|
||||
while (cursor.moveToNext()) {
|
||||
val lat = cursor[LocationColumns.Lat]
|
||||
val lon = cursor[LocationColumns.Lon]
|
||||
val locationId = cursor[LocationColumns.Id]
|
||||
val name = cursor[LocationColumns.Name] ?: continue
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val lat = latIndex?.let { cursor.getDoubleOrNull(it) }
|
||||
val lon = lonIndex?.let { cursor.getDoubleOrNull(it) }
|
||||
val locationId = locationIdIndex?.let { cursor.getStringOrNull(it) }
|
||||
val name = cursor.getStringOrNull(nameIndex) ?: continue
|
||||
|
||||
if (lat != null && lon != null) {
|
||||
results += WeatherLocation.LatLon(lat = lat, lon = lon, name = name)
|
||||
} else if (locationId != null) {
|
||||
results += WeatherLocation.Id(locationId = locationId, name = name)
|
||||
if (lat != null && lon != null) {
|
||||
results += WeatherLocation.LatLon(lat = lat, lon = lon, name = name)
|
||||
} else if (locationId != null) {
|
||||
results += WeatherLocation.Id(locationId = locationId, name = name)
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
|
||||
Reference in New Issue
Block a user