Add weather plugin sdk classes
This commit is contained in:
parent
f327906a3e
commit
24c0899035
@ -2,4 +2,5 @@ package de.mm20.launcher2.plugin
|
||||
|
||||
enum class PluginType {
|
||||
FileSearch,
|
||||
Weather,
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package de.mm20.launcher2.plugin.config
|
||||
|
||||
data class WeatherPluginConfig(
|
||||
val supportsAutoLocation: Boolean,
|
||||
val supportsCustomLocation: Boolean,
|
||||
)
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
@ -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>?
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user