Add weather plugin sdk classes

This commit is contained in:
MM20 2023-12-17 18:02:11 +01:00
parent f327906a3e
commit 24c0899035
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
9 changed files with 585 additions and 9 deletions

View File

@ -2,4 +2,5 @@ package de.mm20.launcher2.plugin
enum class PluginType {
FileSearch,
Weather,
}

View File

@ -0,0 +1,6 @@
package de.mm20.launcher2.plugin.config
data class WeatherPluginConfig(
val supportsAutoLocation: Boolean,
val supportsCustomLocation: Boolean,
)

View File

@ -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"
}
}

View File

@ -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<T>(
@ -97,14 +97,8 @@ abstract class SearchPluginProvider<T>(
query: String,
cancellationSignal: CancellationSignal?
): List<T> {
return runBlocking {
val deferred = async {
search(query)
}
cancellationSignal?.setOnCancelListener {
deferred.cancel()
}
deferred.await()
return launchWithCancellationSignal(cancellationSignal) {
search(query)
}
}

View File

@ -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()
}

View File

@ -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 <T> launchWithCancellationSignal(
cancellationSignal: CancellationSignal?,
block: suspend CoroutineScope.() -> T
): T {
return runBlocking {
val deferred = async(block = block)
cancellationSignal?.setOnCancelListener {
deferred.cancel()
}
deferred.await()
}
}

View File

@ -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,
)

View File

@ -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
}

View File

@ -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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
return query(uri, projection, null, null)
}
override fun query(
uri: Uri,
projection: Array<out String>?,
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<Forecast>? {
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<Forecast>): 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<WeatherLocation>): 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<out String>?): Int {
throw UnsupportedOperationException("This operation is not supported")
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): 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<WeatherLocation> {
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<Forecast>?
/**
* 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<Forecast>?
}