From 24c089903565a44707b6ca93152250c23ae7fae0 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Sun, 17 Dec 2023 18:02:11 +0100 Subject: [PATCH] Add weather plugin sdk classes --- .../de/mm20/launcher2/plugin/PluginType.kt | 1 + .../plugin/config/WeatherPluginConfig.kt | 6 + .../plugin/contracts/WeatherPluginContract.kt | 47 +++ .../sdk/base/SearchPluginProvider.kt | 12 +- .../java/de/mm20/launcher2/sdk/ktx/Address.kt | 11 + .../de/mm20/launcher2/sdk/utils/Coroutines.kt | 19 ++ .../de/mm20/launcher2/sdk/weather/Forecast.kt | 192 ++++++++++++ .../launcher2/sdk/weather/WeatherLocation.kt | 10 + .../launcher2/sdk/weather/WeatherProvider.kt | 296 ++++++++++++++++++ 9 files changed, 585 insertions(+), 9 deletions(-) create mode 100644 core/shared/src/main/java/de/mm20/launcher2/plugin/config/WeatherPluginConfig.kt create mode 100644 core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/WeatherPluginContract.kt create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/ktx/Address.kt create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/utils/Coroutines.kt create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/Forecast.kt create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/WeatherLocation.kt create mode 100644 plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/WeatherProvider.kt diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt index 33d78ad1..ac8b2fcb 100644 --- a/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt +++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/PluginType.kt @@ -2,4 +2,5 @@ package de.mm20.launcher2.plugin enum class PluginType { FileSearch, + Weather, } \ No newline at end of file diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/config/WeatherPluginConfig.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/config/WeatherPluginConfig.kt new file mode 100644 index 00000000..170d0807 --- /dev/null +++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/config/WeatherPluginConfig.kt @@ -0,0 +1,6 @@ +package de.mm20.launcher2.plugin.config + +data class WeatherPluginConfig( + val supportsAutoLocation: Boolean, + val supportsCustomLocation: Boolean, +) \ No newline at end of file diff --git a/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/WeatherPluginContract.kt b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/WeatherPluginContract.kt new file mode 100644 index 00000000..aa1998a4 --- /dev/null +++ b/core/shared/src/main/java/de/mm20/launcher2/plugin/contracts/WeatherPluginContract.kt @@ -0,0 +1,47 @@ +package de.mm20.launcher2.plugin.contracts + +object WeatherPluginContract { + object Paths { + const val Weather = "weather" + const val Locations = "locations" + } + + object ForecastParams { + const val Lat = "lat" + const val Lon = "lon" + const val Id = "id" + const val LocationName = "location_name" + } + + 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 LocationParams { + const val Query = "query" + } + + object LocationColumns { + const val Id = "id" + const val Lat = "lat" + const val Lon = "lon" + const val Name = "name" + } +} \ No newline at end of file diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/SearchPluginProvider.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/SearchPluginProvider.kt index 48bdab4c..43297cb3 100644 --- a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/SearchPluginProvider.kt +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/base/SearchPluginProvider.kt @@ -8,7 +8,7 @@ import android.os.Bundle import android.os.CancellationSignal import de.mm20.launcher2.plugin.config.SearchPluginConfig import de.mm20.launcher2.plugin.contracts.SearchPluginContract -import kotlinx.coroutines.async +import de.mm20.launcher2.sdk.utils.launchWithCancellationSignal import kotlinx.coroutines.runBlocking abstract class SearchPluginProvider( @@ -97,14 +97,8 @@ abstract class SearchPluginProvider( query: String, cancellationSignal: CancellationSignal? ): List { - return runBlocking { - val deferred = async { - search(query) - } - cancellationSignal?.setOnCancelListener { - deferred.cancel() - } - deferred.await() + return launchWithCancellationSignal(cancellationSignal) { + search(query) } } diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/ktx/Address.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/ktx/Address.kt new file mode 100644 index 00000000..aa16940e --- /dev/null +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/ktx/Address.kt @@ -0,0 +1,11 @@ +package de.mm20.launcher2.sdk.ktx + +import android.location.Address + +internal fun Address.formatToString( +): String { + val sb = StringBuilder() + if (locality != null) sb.append(locality).append(", ") + if (countryCode != null) sb.append(countryCode) + return sb.toString() +} \ No newline at end of file diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/utils/Coroutines.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/utils/Coroutines.kt new file mode 100644 index 00000000..89e512fa --- /dev/null +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/utils/Coroutines.kt @@ -0,0 +1,19 @@ +package de.mm20.launcher2.sdk.utils + +import android.os.CancellationSignal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking + +fun launchWithCancellationSignal( + cancellationSignal: CancellationSignal?, + block: suspend CoroutineScope.() -> T +): T { + return runBlocking { + val deferred = async(block = block) + cancellationSignal?.setOnCancelListener { + deferred.cancel() + } + deferred.await() + } +} diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/Forecast.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/Forecast.kt new file mode 100644 index 00000000..7f166848 --- /dev/null +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/Forecast.kt @@ -0,0 +1,192 @@ +package de.mm20.launcher2.sdk.weather + +@JvmInline +value class Temperature internal constructor(internal val kelvin: Double) + +/** + * Temperature in degrees Celsius + */ +val Double.C + get() = Temperature(this + 273.15) + +/** + * Temperature in degrees Fahrenheit + */ +val Double.F + get() = Temperature((this - 32.0) * (5.0 / 9.0) + 273.15) + +/** + * Temperature in Kelvin + */ +val Double.K + get() = Temperature(this) + +@JvmInline +value class WindSpeed internal constructor(internal val metersPerSecond: Double) + +/** + * Wind speed in meters per second + */ +val Double.m_s + get() = WindSpeed(this) + +/** + * Wind speed in kilometers per hour + */ +val Double.km_h + get() = WindSpeed(this * 0.277778) + +/** + * Wind speed in miles per hour + */ +val Double.mph + get() = WindSpeed(this * 0.44704) + +@JvmInline +value class Pressure internal constructor(internal val hPa: Double) + +/** + * Pressure in hectopascal + */ +val Double.hPa + get() = Pressure(this) + +/** + * Pressure in millibar + */ +val Double.mbar + get() = Pressure(this) + +@JvmInline +value class Precipitation internal constructor(internal val mm: Double) + +/** + * Precipitation in millimeters + */ +val Double.mm + get() = Precipitation(this) + +/** + * Precipitation in inches + */ + +val Double.inch + get() = Precipitation(this * 25.4) + + +enum class WeatherIcon { + None, + Clear, + Cloudy, + Cold, + Drizzle, + Haze, + Fog, + Hail, + HeavyThunderstorm, + HeavyThunderstormWithRain, + Hot, + MostlyCloudy, + PartlyCloudy, + Showers, + Sleet, + Snow, + Storm, + Thunderstorm, + ThunderstormWithRain, + Wind, + BrokenClouds, +} + + +data class Forecast( + /** + * Unix timestamp of the time that this forecast is valid for, in milliseconds + */ + val timestamp: Long, + /** + * Unix timestamp of the time that this forecast was created, in milliseconds + */ + val createdAt: Long, + /** + * The temperature + * @see [Double].[C] + * @see [Double].[F] + * @see [Double].[K] + */ + val temperature: Temperature, + /** + * The weather condition + */ + val condition: String, + /** + * The weather icon + */ + val icon: WeatherIcon, + /** + * If true, weather icons will use the moon icon instead of the sun icon + */ + val night: Boolean = false, + + /** + * The minimum temperature + * @see [Double].[C] + * @see [Double].[F] + * @see [Double].[K] + */ + val minTemp: Temperature? = null, + /** + * The maximum temperature + * @see [Double].[C] + * @see [Double].[F] + * @see [Double].[K] + */ + val maxTemp: Temperature? = null, + /** + * Air pressure + * @see [Double].[hPa] + * @see [Double].[mbar] + */ + val pressure: Pressure? = null, + /** + * Air humidity in percent + */ + val humidity: Int? = null, + /** + * Wind speed + * @see [Double].[m_s] + * @see [Double].[km_h] + * @see [Double].[mph] + */ + val windSpeed: WindSpeed? = null, + /** + * Wind direction in degrees + */ + val windDirection: Double? = null, + /** + * Precipitation + * @see [Double].[mm] + * @see [Double].[inch] + */ + val precipitation: Precipitation? = null, + /** + * Rain probability in percent + */ + val rainProbability: Int? = null, + /** + * Clouds in percent + */ + val clouds: Int? = null, + /** + * Location name + */ + val location: String, + /** + * Provider name + */ + val provider: String, + /** + * Url to the provider and more weather information + */ + val providerUrl: String, +) \ No newline at end of file diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/WeatherLocation.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/WeatherLocation.kt new file mode 100644 index 00000000..445d8ed5 --- /dev/null +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/WeatherLocation.kt @@ -0,0 +1,10 @@ +package de.mm20.launcher2.sdk.weather + +sealed interface WeatherLocation { + val name: String + + data class Id(override val name: String, val id: String) : WeatherLocation + + data class LatLon(override val name: String, val lat: Double, val lon: Double) : + WeatherLocation +} diff --git a/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/WeatherProvider.kt b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/WeatherProvider.kt new file mode 100644 index 00000000..253fcf99 --- /dev/null +++ b/plugins/sdk/src/main/java/de/mm20/launcher2/sdk/weather/WeatherProvider.kt @@ -0,0 +1,296 @@ +package de.mm20.launcher2.sdk.weather + +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.location.Geocoder +import android.net.Uri +import android.os.Bundle +import android.os.CancellationSignal +import android.util.Log +import de.mm20.launcher2.plugin.PluginType +import de.mm20.launcher2.plugin.config.WeatherPluginConfig +import de.mm20.launcher2.plugin.contracts.WeatherPluginContract +import de.mm20.launcher2.sdk.base.BasePluginProvider +import de.mm20.launcher2.sdk.ktx.formatToString +import de.mm20.launcher2.sdk.utils.launchWithCancellationSignal +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import kotlin.math.absoluteValue +import kotlin.math.roundToInt + +abstract class WeatherProvider( + val config: WeatherPluginConfig, +) : BasePluginProvider() { + override fun getPluginType(): PluginType { + return PluginType.Weather + } + + override fun onCreate(): Boolean { + return true + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? { + return query(uri, projection, null, null) + } + + override fun query( + uri: Uri, + projection: Array?, + queryArgs: Bundle?, + cancellationSignal: CancellationSignal? + ): Cursor? { + + val context = context ?: return null + checkPermissionOrThrow(context) + + when { + uri.pathSegments.size == 1 && uri.pathSegments.first() == WeatherPluginContract.Paths.Weather -> { + val lat = uri.getQueryParameter(WeatherPluginContract.ForecastParams.Lat) + ?.toDoubleOrNull() + val lon = uri.getQueryParameter(WeatherPluginContract.ForecastParams.Lon) + ?.toDoubleOrNull() + val id = uri.getQueryParameter(WeatherPluginContract.ForecastParams.Id) + val name = uri.getQueryParameter(WeatherPluginContract.ForecastParams.LocationName) + + val forecasts = launchWithCancellationSignal(cancellationSignal) { + getWeatherData(lat, lon, id, name) + } ?: return null + return createForecastCursor(forecasts) + } + + uri.pathSegments.size == 1 && uri.pathSegments.first() == WeatherPluginContract.Paths.Locations -> { + val query = + uri.getQueryParameter(WeatherPluginContract.LocationParams.Query) ?: return null + val locations = launchWithCancellationSignal( + cancellationSignal + ) { + findLocations(query) + } + return createLocationsCursor(locations) + } + } + return super.query(uri, projection, queryArgs, cancellationSignal) + } + + private suspend fun getWeatherData( + lat: Double?, + lon: Double?, + id: String?, + locationName: String? + ): List? { + if (lat != null && lon != null && locationName == null) { + return getWeatherData(lat, lon) + } + if (id != null && locationName != null) { + return getWeatherData(WeatherLocation.Id(id, locationName)) + } + if (locationName != null && lat != null && lon != null) { + return getWeatherData(WeatherLocation.LatLon(locationName, lat, lon)) + } + return null + } + + private fun createForecastCursor(forecasts: List): Cursor { + val cursor = MatrixCursor( + arrayOf( + WeatherPluginContract.ForecastColumns.Timestamp, + WeatherPluginContract.ForecastColumns.CreatedAt, + WeatherPluginContract.ForecastColumns.Temperature, + WeatherPluginContract.ForecastColumns.TemperatureMin, + WeatherPluginContract.ForecastColumns.TemperatureMax, + WeatherPluginContract.ForecastColumns.Pressure, + WeatherPluginContract.ForecastColumns.Humidity, + WeatherPluginContract.ForecastColumns.WindSpeed, + WeatherPluginContract.ForecastColumns.WindDirection, + WeatherPluginContract.ForecastColumns.Precipitation, + WeatherPluginContract.ForecastColumns.RainProbability, + WeatherPluginContract.ForecastColumns.Clouds, + WeatherPluginContract.ForecastColumns.Location, + WeatherPluginContract.ForecastColumns.Provider, + WeatherPluginContract.ForecastColumns.ProviderUrl, + WeatherPluginContract.ForecastColumns.Night, + WeatherPluginContract.ForecastColumns.Icon, + WeatherPluginContract.ForecastColumns.Condition, + ), + forecasts.size, + ) + for (forecast in forecasts) { + cursor.addRow( + arrayOf( + forecast.timestamp, + forecast.createdAt, + forecast.temperature.kelvin, + forecast.minTemp?.kelvin, + forecast.maxTemp?.kelvin, + forecast.pressure?.hPa, + forecast.humidity, + forecast.windSpeed?.metersPerSecond, + forecast.windDirection, + forecast.precipitation?.mm, + forecast.rainProbability, + forecast.clouds, + forecast.location, + forecast.provider, + forecast.providerUrl, + forecast.night, + forecast.icon.name, + forecast.condition, + ) + ) + } + return cursor + } + + fun createLocationsCursor(locations: List): Cursor { + val cursor = MatrixCursor( + arrayOf( + WeatherPluginContract.LocationColumns.Id, + WeatherPluginContract.LocationColumns.Lat, + WeatherPluginContract.LocationColumns.Lon, + WeatherPluginContract.LocationColumns.Name, + ), + locations.size, + ) + for (location in locations) { + if (location is WeatherLocation.Id) { + cursor.addRow( + arrayOf( + location.id, + null, + null, + location.name, + ) + ) + } else if (location is WeatherLocation.LatLon) { + cursor.addRow( + arrayOf( + null, + location.lat, + location.lon, + location.name, + ) + ) + } + } + return cursor + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + throw UnsupportedOperationException("This operation is not supported") + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + throw UnsupportedOperationException("This operation is not supported") + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int { + throw UnsupportedOperationException("This operation is not supported") + } + + override fun getType(uri: Uri): String? { + throw UnsupportedOperationException("This operation is not supported") + } + + /** + * Find locations based on a query string. + * The default implementation uses the Android Geocoder and returns a list of lat lon locations. + * It also supports lat/lon coordinates in the format "[lat] [lon] [name]" or "[lat] [lon]". + */ + open suspend fun findLocations(query: String): List { + val context = context ?: return emptyList() + val parts = query.split(" ", limit = 3) + val lat = parts.getOrNull(0)?.toDoubleOrNull() + val lon = parts.getOrNull(1)?.toDoubleOrNull() + if (lat != null && lon != null && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { + val name = parts.getOrElse(2) { getLocationName(lat, lon) } + return listOf( + WeatherLocation.LatLon(name, lat, lon) + ) + } + if (!Geocoder.isPresent()) return emptyList() + val geocoder = Geocoder(context) + val locations = + withContext(Dispatchers.IO) { + try { + geocoder.getFromLocationName(query, 10) + } catch (e: IOException) { + Log.e("WeatherProvider", "Failed to lookup location", e) + emptyList() + } + } ?: emptyList() + return locations.mapNotNull { + WeatherLocation.LatLon( + lat = it.latitude, + lon = it.longitude, + name = it.formatToString() + ) + } + } + + /** + * Get the name of a location based on lat/lon coordinates. + * The default implementation uses the Android Geocoder. + * If the Geocoder is not available, the lat/lon coordinates are formatted as a string. + */ + open suspend fun getLocationName(lat: Double, lon: Double): String { + val context = context ?: return formatLatLon(lat, lon) + if (!Geocoder.isPresent()) return formatLatLon(lat, lon) + return withContext(Dispatchers.IO) { + try { + Geocoder(context).getFromLocation(lat, lon, 1) + ?.firstOrNull() + ?.formatToString() ?: formatLatLon(lat, lon) + } catch (e: IOException) { + formatLatLon(lat, lon) + } + } + } + + private fun formatLatLon(lat: Double, lon: Double): String { + val absLat = lat.absoluteValue + val absLon = lon.absoluteValue + + val dLat = absLat.toInt() + val dLon = absLon.toInt() + + val mLat = ((absLat - dLat) * 60).roundToInt() + + val mLon = ((absLon - dLon) * 60).roundToInt() + + + val dmsLat = "$dLat°$mLat'${if (lat >= 0) "N" else "S"}" + + val dmsLon = "$dLon°$mLon'${if (lat >= 0) "E" else "W"}" + + return "$dmsLat $dmsLon" + } + + /** + * Get weather data for the current location. This is called when the user has set + * the location to "Current location". + */ + abstract suspend fun getWeatherData(lat: Double, lon: Double): List? + + /** + * Get weather data for a set location. This is called when the user has set + * the location to a custom location. + * @param location the location that the user has set. This is guaranteed + * to be one of the locations returned by [findLocations]. + * @return the weather data for the given location + * Note that returned forecasts should use the same location name as the location parameter. + */ + abstract suspend fun getWeatherData(location: WeatherLocation): List? +} \ No newline at end of file