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
+16 -6
View File
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.plugin.serialization)
alias(libs.plugins.dokka)
`maven-publish`
signing
@@ -46,6 +47,11 @@ android {
}
}
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.bundles.kotlin)
}
tasks.dokkaHtml {
outputDirectory.set(layout.buildDirectory.dir("dokka"))
}
@@ -95,12 +101,16 @@ publishing {
}
repositories {
mavenLocal()
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/MM2-0/Kvaesitso")
credentials {
username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME")
password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN")
val ghUser = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME")
if (ghUser == "MM2-0") {
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/MM2-0/Kvaesitso")
credentials {
username =
project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME")
password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN")
}
}
}
}
@@ -3,4 +3,5 @@ package de.mm20.launcher2.plugin
enum class PluginType {
FileSearch,
Weather,
LocationSearch,
}
@@ -1,9 +1,6 @@
package de.mm20.launcher2.plugin.config
import android.os.Bundle
import de.mm20.launcher2.plugin.config.StorageStrategy
data class SearchPluginConfig(
data class QueryPluginConfig(
/**
* Strategy to store items from this provider in the launcher database
* @see [StorageStrategy]
@@ -1,9 +1,13 @@
package de.mm20.launcher2.plugin.config
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Defines how the launcher should store search results from a plugin (i.e. when the search result is
* added to favorites).
*/
@Serializable
enum class StorageStrategy {
/**
* The launcher only stores the ID and the plugin provider for the search result. To restore the
@@ -13,6 +17,7 @@ enum class StorageStrategy {
* particular, the plugin provider must be able to restore a search result without any network
* requests.
*/
@SerialName("ref")
StoreReference,
/**
@@ -21,13 +26,6 @@ enum class StorageStrategy {
* Use this strategy if your plugin needs to perform network requests to retrieve search
* results and if you don't want to implement a cache for search results.
*/
@SerialName("copy")
StoreCopy,
/**
* The launcher stores all relevant information in its own internal database, like [StoreCopy].
* A fresh copy is fetched from the plugin provider when the user opens the search result's
* detail view. This allows the plugin provider to update the search result at a later point in
* time, without the time constraints of [StoreReference].
*/
Deferred,
}
@@ -0,0 +1,271 @@
package de.mm20.launcher2.plugin.contracts
import android.database.Cursor
import android.os.Bundle
import android.util.Log
import de.mm20.launcher2.plugin.data.RowBuilderScope
import de.mm20.launcher2.serialization.Json
import kotlinx.serialization.encodeToString
abstract class Columns {
internal val columns = mutableSetOf<String>()
}
@Suppress("UNCHECKED_CAST")
internal inline fun <reified T : Any> Columns.column(name: String): Column<T> {
val column = when (T::class) {
Int::class -> IntColumn(name)
Long::class -> LongColumn(name)
String::class -> StringColumn(name)
Short::class -> ShortColumn(name)
Boolean::class -> BooleanColumn(name)
Double::class -> DoubleColumn(name)
Float::class -> FloatColumn(name)
ByteArray::class -> BlobColumn(name)
else -> {
if (T::class.java.isEnum) {
SerializableColumn(
name,
{ it.toString() },
{ v -> T::class.java.enumConstants?.find { it.toString() == v } }
)
} else {
SerializableColumn(
name,
{ Json.Lenient.encodeToString(it) },
{ Json.Lenient.decodeFromString(it) }
)
}
}
} as Column<T>
columns.add(name)
return column
}
sealed interface Column<T> {
val name: String
fun Cursor.readAtIndex(index: Int): T?
fun RowBuilderScope.toCursorValue(value: T?): Any? {
return value
}
fun Bundle.put(value: T?)
fun Bundle.get(): T?
}
internal class IntColumn(
override val name: String
) : Column<Int> {
override fun Cursor.readAtIndex(index: Int): Int {
return getInt(index)
}
override fun Bundle.put(value: Int?) {
if (value == null) {
remove(name)
} else {
putInt(name, value)
}
}
override fun Bundle.get(): Int {
return getInt(name)
}
}
internal class LongColumn(
override val name: String
) : Column<Long> {
override fun Cursor.readAtIndex(index: Int): Long {
return getLong(index)
}
override fun Bundle.put(value: Long?) {
if (value == null) {
remove(name)
} else {
putLong(name, value)
}
}
override fun Bundle.get(): Long {
return getLong(name)
}
}
internal class DoubleColumn(
override val name: String
) : Column<Double> {
override fun Cursor.readAtIndex(index: Int): Double {
return getDouble(index)
}
override fun Bundle.put(value: Double?) {
if (value == null) {
remove(name)
} else {
putDouble(name, value)
}
}
override fun Bundle.get(): Double {
return getDouble(name)
}
}
internal class FloatColumn(
override val name: String
) : Column<Float> {
override fun Cursor.readAtIndex(index: Int): Float {
return getFloat(index)
}
override fun Bundle.put(value: Float?) {
if (value == null) {
remove(name)
} else {
putFloat(name, value)
}
}
override fun Bundle.get(): Float {
return getFloat(name)
}
}
internal class StringColumn(
override val name: String
) : Column<String> {
override fun Cursor.readAtIndex(index: Int): String? {
return getString(index)
}
override fun Bundle.put(value: String?) {
if (value == null) {
remove(name)
} else {
putString(name, value)
}
}
override fun Bundle.get(): String? {
return getString(name)
}
}
internal class ShortColumn(
override val name: String
) : Column<Short> {
override fun Cursor.readAtIndex(index: Int): Short {
return getShort(index)
}
override fun Bundle.put(value: Short?) {
if (value == null) {
remove(name)
} else {
putShort(name, value)
}
}
override fun Bundle.get(): Short {
return getShort(name)
}
}
internal class BooleanColumn(
override val name: String
) : Column<Boolean> {
override fun Cursor.readAtIndex(index: Int): Boolean {
return getInt(index) != 0
}
override fun RowBuilderScope.toCursorValue(value: Boolean?): Any? {
value ?: return null
return if (value) 1 else 0
}
override fun Bundle.put(value: Boolean?) {
if (value == null) {
remove(name)
} else {
putBoolean(name, value)
}
}
override fun Bundle.get(): Boolean? {
return getBoolean(name)
}
}
internal class BlobColumn(
override val name: String
) : Column<ByteArray> {
override fun Cursor.readAtIndex(index: Int): ByteArray? {
return getBlob(index)
}
override fun Bundle.put(value: ByteArray?) {
if (value == null) {
remove(name)
} else {
putByteArray(name, value)
}
}
override fun Bundle.get(): ByteArray? {
return getByteArray(name)
}
}
internal class SerializableColumn<T>(
override val name: String,
val serialize: (T) -> String,
val deserialize: (String) -> T
) : Column<T> {
override fun Cursor.readAtIndex(index: Int): T? {
val string = getString(index)
try {
return deserialize(string)
} catch (e: Exception) {
Log.e("MM20", "Failed to read column value", e)
return null
}
}
override fun RowBuilderScope.toCursorValue(value: T?): Any? {
return try {
serialize(value ?: return null)
} catch (e: Exception) {
Log.e("MM20", "Failed to write column value", e)
null
}
}
override fun Bundle.put(value: T?) {
val serialized = try {
value?.let(serialize)
} catch (e: Exception) {
Log.e("MM20", "Failed to serialize column value", e)
null
}
if (serialized == null) {
remove(name)
} else {
putString(name, serialized)
}
}
override fun Bundle.get(): T? {
val string = getString(name) ?: return null
return try {
deserialize(string)
} catch (e: Exception) {
Log.e("MM20", "Failed to deserialize column value", e)
null
}
}
}
@@ -2,64 +2,63 @@ package de.mm20.launcher2.plugin.contracts
abstract class FilePluginContract {
object FileColumns {
object FileColumns: Columns() {
/**
* The unique ID of the file.
* Type: String
*/
const val Id = "id"
val Id = column<String>("id")
/**
* The display name of the file.
* Type: String
*/
const val DisplayName = "display_name"
val DisplayName = column<String>("display_name")
/**
* The MIME type of the file. This is used to determine how to open the file. Make sure that
* this is either a common MIME type or that your app can handle this MIME type.
* Type: String?
*/
const val MimeType = "mime_type"
val MimeType = column<String>("mime_type")
/**
* The size of the file in bytes.
* Type: Long?
*/
const val Size = "size"
val Size = column<Long>("size")
/**
* The display path of the file.
* Type: String?
*/
const val Path = "path"
val Path = column<String>("path")
/**
* The URI to view the file.
* Type: String?
*/
const val ContentUri = "uri"
val ContentUri = column<String>("uri")
const val ThumbnailUri = "thumbnail_uri"
val ThumbnailUri = column<String>("thumbnail_uri")
/**
* Whether the file is a directory.
* Type: Int
*/
const val IsDirectory = "is_directory"
val IsDirectory = column<Boolean>("is_directory")
const val Owner = "owner"
val Owner = column<String>("owner")
const val MetaTitle = "meta_title"
const val MetaArtist = "meta_artist"
const val MetaAlbum = "meta_album"
const val MetaDuration = "meta_duration"
const val MetaYear = "meta_year"
const val MetaWidth = "meta_width"
const val MetaHeight = "meta_height"
const val MetaLocation = "meta_location"
const val MetaAppName = "meta_app_name"
const val MetaAppPackageName = "meta_app_package_name"
const val MetaAppMinSdkVersion = "meta_app_min_sdk_version"
val MetaTitle = column<String>("meta_title")
val MetaArtist = column<String>("meta_artist")
val MetaAlbum = column<String>("meta_album")
val MetaDuration = column<Long>("meta_duration")
val MetaYear = column<Int>("meta_year")
val MetaWidth = column<Int>("meta_width")
val MetaHeight = column<Int>("meta_height")
val MetaLocation = column<String>("meta_location")
val MetaAppName = column<String>("meta_app_name")
val MetaAppPackageName = column<String>("meta_app_package_name")
}
}
@@ -0,0 +1,143 @@
package de.mm20.launcher2.plugin.contracts
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
abstract class LocationPluginContract {
object Paths {
const val Search = "search"
const val Get = "get"
}
object Params {
/**
* Search query.
* Type: String
*/
const val Query = "query"
/**
* Latitude of user's current location in degrees.
* Type: Double?
*/
const val UserLatitude = "user_latitude"
/**
* Longitude of user's current location in degrees.
* Type: Double?
*/
const val UserLongitude = "user_longitude"
/**
* Search radius in meters.
* Type: Long
*/
const val SearchRadius = "search_radius"
/**
* Whether to allow network requests.
* Type: Boolean
*/
const val AllowNetwork = "network"
}
object GetParams {
/**
* Unique identifier of location to look up.
* Type: String
*/
const val Id = "id"
}
object LocationColumns : Columns() {
/**
* Unique identifier of location.
* Type: String
*/
val Id = column<String>("id")
/**
* Display name of location.
* Type: String
*/
val Label = column<String>("label")
/**
* Latitude of location in degrees.
* Type: Double
*/
val Latitude = column<Double>("latitude")
/**
* Longitude of location in degrees.
* Type: Double
*/
val Longitude = column<Double>("longitude")
/**
* Url for users to report / fix incorrect data.
* Type: String?
*/
val FixMeUrl = column<String>("fix_me_url")
/**
* Icon of location.
* Type: String? (enum LocationIcon)
*/
val Icon = column<LocationIcon>("icon")
/**
* Location category.
* Type: String?
*/
val Category = column<String>("category")
/**
* Street name of location.
* Type: String? (JSON)
*/
val Address = column<Address>("address")
/**
* Opening schedule of location, encoded as JSON.
* Type: String? (JSON)
*/
val OpeningSchedule = column<OpeningSchedule>("opening_schedule")
/**
* Website URL of location.
* Type: String?
*/
val WebsiteUrl = column<String>("website_url")
/**
* Phone number of location.
* Type: String?
*/
val PhoneNumber = column<String>("phone_number")
val EmailAddress = column<String>("email_address")
/**
* User rating of location, from 0.0 (worst) to 1.0 (best)
* Type: Float?
*/
val UserRating = column<Float>("user_rating")
val UserRatingCount = column<Int>("user_rating_count")
/**
* Public transport departures originating from this location, encoded as JSON.
* Type: String? (JSON)
*/
val Departures = column<List<Departure>>("departures")
/**
* Attribution information for location data.
* Type: String? (JSON)
*/
val Attribution = column<Attribution>("attribution")
}
}
@@ -4,7 +4,21 @@ abstract class SearchPluginContract {
object Paths {
const val Search = "search"
const val Root = "root"
const val QueryParam = "query"
const val AllowNetworkParam = "network"
const val Refresh = "refresh"
@Deprecated("Use Paths.Query instead")
const val QueryParam = Params.Query
@Deprecated("Use Params.AllowNetwork instead")
const val AllowNetworkParam = Params.AllowNetwork
@Deprecated("Use Params.Lang instead")
const val LangParam = Params.Lang
}
object Params {
const val AllowNetwork = "network"
const val Lang = "lang"
const val UpdatedAt = "updatedAt"
const val Query = "query"
}
object Extras {
const val NotUpdated = "notUpdated"
}
}
@@ -1,5 +1,7 @@
package de.mm20.launcher2.plugin.contracts
import de.mm20.launcher2.weather.WeatherIcon
object WeatherPluginContract {
object Paths {
const val Forecasts = "forecasts"
@@ -14,25 +16,25 @@ object WeatherPluginContract {
const val Language = "lang"
}
object ForecastColumns {
const val Timestamp = "timestamp"
const val CreatedAt = "created_at"
const val Temperature = "temperature"
const val TemperatureMin = "temperature_min"
const val TemperatureMax = "temperature_max"
const val Pressure = "pressure"
const val Humidity = "humidity"
const val WindSpeed = "wind_speed"
const val WindDirection = "wind_direction"
const val Precipitation = "precipitation"
const val RainProbability = "rain_probability"
const val Clouds = "clouds"
const val Location = "location"
const val Provider = "provider"
const val ProviderUrl = "provider_url"
const val Night = "night"
const val Icon = "icon"
const val Condition = "condition"
object ForecastColumns : Columns() {
val Timestamp = column<Long>("timestamp")
val CreatedAt = column<Long>("created_at")
val Temperature = column<Double>("temperature")
val TemperatureMin = column<Double>("temperature_min")
val TemperatureMax = column<Double>("temperature_max")
val Pressure = column<Double>("pressure")
val Humidity = column<Int>("humidity")
val WindSpeed = column<Double>("wind_speed")
val WindDirection = column<Double>("wind_direction")
val Precipitation = column<Double>("precipitation")
val RainProbability = column<Int>("rain_probability")
val Clouds = column<Int>("clouds")
val Location = column<String>("location")
val Provider = column<String>("provider")
val ProviderUrl = column<String>("provider_url")
val Night = column<Boolean>("night")
val Icon = column<WeatherIcon>("icon")
val Condition = column<String>("condition")
}
object LocationParams {
@@ -40,10 +42,10 @@ object WeatherPluginContract {
const val Language = "lang"
}
object LocationColumns {
const val Id = "id"
const val Lat = "lat"
const val Lon = "lon"
const val Name = "name"
object LocationColumns : Columns() {
val Id = column<String>("id")
val Lat = column<Double>("lat")
val Lon = column<Double>("lon")
val Name = column<String>("name")
}
}
@@ -0,0 +1,17 @@
package de.mm20.launcher2.plugin.data
import android.os.Bundle
import de.mm20.launcher2.plugin.contracts.Column
operator fun <T> Bundle.get(column: Column<T>): T? {
return with(column) {
if (!containsKey(column.name)) return null
get()
}
}
operator fun <T> Bundle.set(column: Column<T>, value: T?) {
with(column) {
put(value)
}
}
@@ -0,0 +1,77 @@
package de.mm20.launcher2.plugin.data
import android.database.Cursor
import android.database.MatrixCursor
import android.util.Log
import de.mm20.launcher2.plugin.contracts.Column
import de.mm20.launcher2.plugin.contracts.Columns
interface ColumnsScope {
operator fun <T : Any> Cursor.get(column: Column<T>): T?
operator fun Cursor.contains(column: Column<*>): Boolean
}
internal class ColumnsScopeImpl(
private val columnIndices: Map<String, Int>,
private val cursor: Cursor
) : ColumnsScope {
override operator fun <T : Any> Cursor.get(column: Column<T>): T? {
val index = columnIndices[column.name] ?: return null
if (index == -1) return null
if (cursor.isNull(index)) return null
try {
return with(column) { cursor.readAtIndex(index) }
} catch (e: Exception) {
Log.e("MM20", "Failed to get column value", e)
}
return null
}
override fun Cursor.contains(column: Column<*>): Boolean {
return columnIndices.containsKey(column.name)
}
}
fun Cursor.withColumns(columns: Columns, block: ColumnsScope.() -> Unit) {
val scope = ColumnsScopeImpl(
columns.columns.associateWith { name -> getColumnIndex(name) },
this
)
scope.block()
}
fun <T> buildCursor(
columns: Columns,
data: List<T>,
rowBuilder: RowBuilderScope.(item: T) -> Unit
): Cursor {
val cursor = MatrixCursor(columns.columns.toTypedArray(), data.size)
val values = Array<Any?>(columns.columns.size) { null }
val scope = RowBuilderScopeImpl(
columnIndices = columns.columns.withIndex().associate { (index, name) -> name to index },
values = values
)
repeat(data.size) {
values.fill(null)
scope.rowBuilder(data[it])
cursor.addRow(scope.values)
}
return cursor
}
interface RowBuilderScope {
fun <T : Any> put(column: Column<T>, value: T?)
}
internal class RowBuilderScopeImpl(
private val columnIndices: Map<String, Int>,
internal val values: Array<Any?>
) : RowBuilderScope {
override fun <T : Any> put(column: Column<T>, value: T?) {
value ?: return
val index = columnIndices[column.name] ?: return
values[index] = with(column) { toCursorValue(value) }
}
}
@@ -0,0 +1,35 @@
package de.mm20.launcher2.search.location
import kotlinx.serialization.Serializable
@Serializable
data class Address(
/**
* Address line 1 (e.g. street name and number)
*/
val address: String? = null,
/**
* Address line 2 (e.g. apartment number)
*/
val address2: String? = null,
/**
* Address line 3 (e.g. additional information)
*/
val address3: String? = null,
/**
* City
*/
val city: String? = null,
/**
* State, province, or region
*/
val state: String? = null,
/**
* Postal code
*/
val postalCode: String? = null,
/**
* Country
*/
val country: String? = null,
)
@@ -0,0 +1,13 @@
package de.mm20.launcher2.search.location
import android.net.Uri
import de.mm20.launcher2.serialization.UriSerializer
import kotlinx.serialization.Serializable
@Serializable
data class Attribution(
val text: String? = null,
@Serializable(with = UriSerializer::class)
val iconUrl: Uri? = null,
val url: String? = null,
)
@@ -0,0 +1,47 @@
package de.mm20.launcher2.search.location
import android.graphics.Color
import de.mm20.launcher2.serialization.ColorSerializer
import de.mm20.launcher2.serialization.DurationSerializer
import de.mm20.launcher2.serialization.ZonedDateTimeSerializer
import kotlinx.serialization.Serializable
import java.time.Duration
import java.time.ZonedDateTime
@Serializable
data class Departure(
/**
* The scheduled time of the departure.
*/
@Serializable(with = ZonedDateTimeSerializer::class)
val time: ZonedDateTime,
/**
* The delay of the departure.
* `Duration.ZERO` if the departure is on time,
* `null` if no real-time data is available.
*/
@Serializable(with = DurationSerializer::class)
val delay: Duration?,
/**
* Name of the line (i.e. "11", "U2", "S1").
*/
val line: String,
val lastStop: String?,
val type: LineType? = null,
@Serializable(with = ColorSerializer::class)
val lineColor: Color?,
)
enum class LineType {
Bus,
Tram,
Subway,
CommuterTrain,
RegionalTrain,
Train,
HighSpeedTrain,
Boat,
Monorail,
CableCar,
Airplane,
}
@@ -0,0 +1,139 @@
package de.mm20.launcher2.search.location
enum class LocationIcon {
Car,
CarRental,
CarRepair,
CarWash,
ChargingStation,
GasStation,
GenericTransit,
Parking,
Bus,
Tram,
Train,
Subway,
CableCar,
Airport,
Boat,
Taxi,
Moped,
Bike,
Motorcycle,
ElectricScooter,
ArtGallery,
Museum,
Theater,
MovieTheater,
AmusementPark,
NightClub,
ConcertHall,
Stadium,
Casino,
Circus,
Hotel,
Restaurant,
Cafe,
FastFood,
Pizza,
Burger,
Kebab,
IceCream,
Ramen,
Soup,
Bar,
Brunch,
Breakfast,
Pub,
JapaneseCuisine,
AsianCuisine,
Shopping,
Florist,
Kiosk,
FurnitureStore,
CellPhoneStore,
BookStore,
ClothingStore,
ConvenienceStore,
DiscountStore,
JewelryStore,
LiquorStore,
PetStore,
ShoppingMall,
Supermarket,
Bakery,
Optician,
Pharmacy,
HairSalon,
Laundromat,
Sports,
FitnessCenter,
Soccer,
Basketball,
Tennis,
Golf,
Baseball,
AmericanFootball,
Hiking,
Swimming,
Surfing,
Motorsports,
Handball,
Volleyball,
Skiing,
Kayaking,
Skateboarding,
Cricket,
MartialArts,
NordicWalking,
Paragliding,
Gymnastics,
Snowboarding,
Hockey,
Rugby,
Bank,
Atm,
Physician,
Dentist,
Hospital,
Clinic,
Park,
Forest,
Monument,
Church,
Mosque,
Synagogue,
BuddhistTemple,
HinduTemple,
Candle,
GovernmentBuilding,
Police,
FireDepartment,
Courthouse,
PostOffice,
Library,
School,
University,
PublicBathroom;
companion object {
fun valueOfOrNull(string: String): LocationIcon? {
return try {
valueOf(string)
} catch (e: IllegalArgumentException) {
null
}
}
}
}
@@ -0,0 +1,30 @@
package de.mm20.launcher2.search.location
import de.mm20.launcher2.serialization.DurationSerializer
import de.mm20.launcher2.serialization.LocalTimeSerializer
import de.mm20.launcher2.serialization.OpeningScheduleSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.DayOfWeek
import java.time.Duration
import java.time.LocalTime
@Serializable
data class OpeningHours(
@JsonNames("day")
val dayOfWeek: DayOfWeek,
@JsonNames("openingTime")
@Serializable(with = LocalTimeSerializer::class)
val startTime: LocalTime,
@Serializable(with = DurationSerializer::class)
val duration: Duration
) {
override fun toString(): String = "$dayOfWeek $startTime-${startTime.plus(duration)}"
}
@Serializable(with = OpeningScheduleSerializer::class)
sealed interface OpeningSchedule {
data object TwentyFourSeven : OpeningSchedule
@Serializable
data class Hours(@Serializable val openingHours: List<OpeningHours>) : OpeningSchedule
}
@@ -0,0 +1,21 @@
package de.mm20.launcher2.serialization
import android.graphics.Color
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object ColorSerializer: KSerializer<Color> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.INT)
override fun deserialize(decoder: Decoder): Color {
return Color.valueOf(decoder.decodeInt())
}
override fun serialize(encoder: Encoder, value: Color) {
encoder.encodeInt(value.toArgb())
}
}
@@ -0,0 +1,22 @@
package de.mm20.launcher2.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.DayOfWeek
object DayOfWeekSerializer : KSerializer<DayOfWeek> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.INT)
override fun deserialize(decoder: Decoder): DayOfWeek {
return DayOfWeek.of(decoder.decodeInt())
}
override fun serialize(encoder: Encoder, value: DayOfWeek) {
encoder.encodeInt(value.value)
}
}
@@ -0,0 +1,20 @@
package de.mm20.launcher2.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Duration
object DurationSerializer: KSerializer<Duration> {
override val descriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: Duration) {
encoder.encodeLong(value.toMillis())
}
override fun deserialize(decoder: Decoder): Duration {
return Duration.ofMillis(decoder.decodeLong())
}
}
@@ -0,0 +1,17 @@
package de.mm20.launcher2.serialization
/**
* Default Json serializer configurations
*/
object Json {
/**
* A Json serializer configuration that aims to be as forgiving as possible.
* Suitable for external data sources, and legacy data that may not be fully compliant with the current schema.
*/
val Lenient = kotlinx.serialization.json.Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
coerceInputValues = true
}
}
@@ -0,0 +1,21 @@
package de.mm20.launcher2.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalTime
object LocalTimeSerializer: KSerializer<LocalTime> {
override val descriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: LocalTime) {
// We use millis here for backwards compatibility in LocationSerializer
encoder.encodeLong(value.toNanoOfDay() / 1000000L)
}
override fun deserialize(decoder: Decoder): LocalTime {
return LocalTime.ofNanoOfDay(decoder.decodeLong() * 1000000L)
}
}
@@ -0,0 +1,55 @@
package de.mm20.launcher2.serialization
import android.util.Log
import de.mm20.launcher2.search.location.OpeningSchedule
import kotlinx.serialization.KSerializer
import kotlinx.serialization.PolymorphicSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
object OpeningScheduleSerializer : KSerializer<OpeningSchedule> {
override val descriptor = PolymorphicSerializer(OpeningSchedule::class).descriptor
override fun serialize(encoder: Encoder, value: OpeningSchedule) {
when (value) {
is OpeningSchedule.TwentyFourSeven -> encoder.encodeString("twentyFourSeven")
is OpeningSchedule.Hours -> encoder.encodeSerializableValue(
OpeningSchedule.Hours.serializer(),
value
)
}
}
override fun deserialize(decoder: Decoder): OpeningSchedule {
decoder as JsonDecoder
val jsonElement = decoder.decodeJsonElement()
if ((jsonElement as? JsonPrimitive)?.content == "twentyFourSeven") {
return OpeningSchedule.TwentyFourSeven
}
if ("openingHours" in jsonElement.jsonObject.keys &&
/* backwards compatibility */ !jsonElement.jsonObject["openingHours"]!!.jsonArray.isEmpty()
) {
return try {
decoder.json.decodeFromJsonElement<OpeningSchedule.Hours>(
jsonElement
)
} catch (e: SerializationException) {
Log.e("MM20", "Failed to deserialize OpeningSchedule.Hours", e)
return OpeningSchedule.Hours(openingHours = emptyList())
}
}
// fallback in case we receive data which was serialized before introducing OpeningScheduleSerializer.
// here openingHours is empty, in which case it has to be 24/7, else it would indicate corrupted data.
return OpeningSchedule.TwentyFourSeven
}
}
@@ -0,0 +1,21 @@
package de.mm20.launcher2.serialization
import android.net.Uri
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object UriSerializer : KSerializer<Uri> {
override val descriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Uri) {
// We use millis here for backwards compatibility in LocationSerializer
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): Uri {
return Uri.parse(decoder.decodeString())
}
}
@@ -0,0 +1,27 @@
package de.mm20.launcher2.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
object ZonedDateTimeSerializer : KSerializer<ZonedDateTime> {
override val descriptor = PrimitiveSerialDescriptor(javaClass.canonicalName!!, PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: ZonedDateTime) {
encoder.encodeLong(
value.toEpochSecond()
)
}
override fun deserialize(decoder: Decoder): ZonedDateTime {
return ZonedDateTime.ofInstant(
Instant.ofEpochSecond(decoder.decodeLong()),
ZoneId.systemDefault()
)
}
}
@@ -0,0 +1,25 @@
package de.mm20.launcher2.weather
enum class WeatherIcon {
Unknown,
Clear,
Cloudy,
Cold,
Drizzle,
Haze,
Fog,
Hail,
HeavyThunderstorm,
HeavyThunderstormWithRain,
Hot,
MostlyCloudy,
PartlyCloudy,
Showers,
Sleet,
Snow,
Storm,
Thunderstorm,
ThunderstormWithRain,
Wind,
BrokenClouds,
}