Add support for location plugins (#772)

* Cherry pick location refactor

* Refactor :data:openstreetmaps to :data:locations

* contract, plugin sdk

* Implement serialization, module tweaks

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

* settings for location plugins

* Try not to be too lazy

* more fiddling with the plugin SDK

* add departures in MapView with mock data for debug builds

* change icons

* add boats

* animate departure lazycolumn

* Add MarqueeText for text overflow handling

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

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

* sort by time

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

* ditch `showLocationOnMap`

* LocationItem: make `showOpeningSchedule` toggleable

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

* LocationItem: resolve TODOs

* MapTiles: ditch unused code, animate userIndicator

* Reintroduce departure list

* Add LineColor

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

* typealias -> import

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

* Add settings migration

* Set plugin SDK version to 1.2.0-SNAPSHOT

* Deduplicate shared plugin classes, use kotlinx.serialization

* Fix imports

* Use ZonedDateTime for depature times

* Add more line types

* Rewrite location serialization

* Replace street/houseNumber with address

* Add attribution field

* Add plugin config

* Reject location search requests without lat lon parameters

* Add default values to plugin location class

* Don't crash if column value is null

* Add docs comments to LocationCategory values

* Refactor OpeningSchedule as polymorphic

* remove dead corpse *ahem* code

* Split LocationCategory into category and icon

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

* Add more location icons

* Fix (?) location deserializer

* Add more location icons

* More icons

* Meh

* Add Pub

* Disable Github Maven repo if credentials are missing

* Add location search specific settings to plugin details screen

* Add language parameter

* Unbreak the build

* Refactor plugin SDK (with breaking changes)

* Set plugin SDK version to 2.0.0-SNAPSHOT

* Document SDK breaking changes

* Implement LocationProvider.getQuery

* Add a typesafe cursor API

* Oops I did it again

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

* Add missing return statement

* Fix list serialization

* Departure time UI adjustment

* Use typesafe cursor for weather plugins

* Add userRatingCount and emailAddress fields

* grrr

* Rename and extend LineTypes

* Add default lineType to Departure to fix serialization errors

* Fix refreshing stored plugin locations

* Adapt line name column width to available departures

* Fix plugin settings screen category overlap

* add LocationItem.GenericTransit

* Fix crash during deserialization of locations

* Update SDK docs

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

before anyone gets sued

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

* Add typesafe Bundle API

* Implement plugin API changes

* Plugin SDK: Fix refresh result not being returned

* Update docs

* apply alpha to departures that have departured

* better (maybe): reduce saturation instead of alpha

* Add default values for Attribution

* Display attribution

* Rearrange location result layout

* Reduce searchable update interval to 1 minute

* Pass last update time to refresh function

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

* categorize osm location

* Update docs

* Optimize location search

- run providers in parallel
- flatten code

* add experimental address parsing for OSM

* add poi_category_townhall

* Fix popup closing when favorites items are updated

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

This reverts commit fc517fd066c7f8109b6d6df2d4f536af66398207.

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

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

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

* Move poi strings back to strings.xml

* Update Jetpack Compose

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

* Move address-formatter back to its original package

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph
2024-06-14 11:57:03 +02:00
committed by GitHub
co-authored by MM20
parent cfe80ff3e5
commit 65a9c8c1fe
145 changed files with 5804 additions and 2199 deletions
@@ -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
@@ -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)
}
}