Implement plugin weather provider

This commit is contained in:
MM20 2023-12-22 01:01:59 +01:00
parent d80e4c66a4
commit 211f30170c
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
7 changed files with 366 additions and 9 deletions

View File

@ -1,5 +1,7 @@
package de.mm20.launcher2.plugin.config
import android.os.Bundle
data class WeatherPluginConfig(
/**
* Minimum time (in ms) that needs to pass before the provider can be queried again.
@ -7,4 +9,22 @@ data class WeatherPluginConfig(
* or weather provider.
*/
val minUpdateInterval: Long = 60 * 60 * 1000L,
) {
fun toBundle(): Bundle {
return Bundle().apply {
putLong("minUpdateInterval", minUpdateInterval)
}
}
companion object {
operator fun invoke(bundle: Bundle): WeatherPluginConfig {
return WeatherPluginConfig(
minUpdateInterval = bundle.getLong(
"minUpdateInterval",
60 * 60 * 1000L
),
)
}
}
}

View File

@ -2,7 +2,7 @@ package de.mm20.launcher2.plugin.contracts
object WeatherPluginContract {
object Paths {
const val Weather = "weather"
const val Forecasts = "forecasts"
const val Locations = "locations"
}

View File

@ -5,13 +5,14 @@ import de.mm20.launcher2.weather.brightsky.BrightSkyProvider
import de.mm20.launcher2.weather.here.HereProvider
import de.mm20.launcher2.weather.metno.MetNoProvider
import de.mm20.launcher2.weather.openweathermap.OpenWeatherMapProvider
import de.mm20.launcher2.weather.plugin.PluginWeatherProvider
import de.mm20.launcher2.weather.settings.WeatherSettings
import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named
import org.koin.dsl.module
val weatherModule = module {
single<WeatherRepository> { WeatherRepositoryImpl(androidContext(), get(), get()) }
single<WeatherRepository> { WeatherRepositoryImpl(androidContext(), get(), get(), get()) }
single<WeatherSettings> { WeatherSettings(androidContext()) }
factory<Backupable>(named<WeatherSettings>()) { get<WeatherSettings>() }
factory<WeatherProvider> { (providerId: String) ->
@ -20,7 +21,7 @@ val weatherModule = module {
MetNoProvider.Id -> MetNoProvider(androidContext(), get())
HereProvider.Id -> HereProvider(androidContext())
BrightSkyProvider.Id -> BrightSkyProvider(androidContext())
else -> TODO("Implement plugin provider")
else -> PluginWeatherProvider(androidContext(), providerId)
}
}
}

View File

@ -6,8 +6,9 @@ import org.koin.core.parameter.parametersOf
interface WeatherProvider {
val updateInterval: Long
get() = 1000 * 60 * 60L
suspend fun getUpdateInterval(): Long {
return 1000 * 60 * 60L
}
suspend fun getWeatherData(location: WeatherLocation): List<Forecast>?
suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>?

View File

@ -11,6 +11,8 @@ import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.plugin.PluginRepository
import de.mm20.launcher2.plugin.PluginType
import de.mm20.launcher2.weather.brightsky.BrightSkyProvider
import de.mm20.launcher2.weather.here.HereProvider
import de.mm20.launcher2.weather.metno.MetNoProvider
@ -39,6 +41,7 @@ internal class WeatherRepositoryImpl(
private val context: Context,
private val database: AppDatabase,
private val settings: WeatherSettings,
private val pluginRepository: PluginRepository,
) : WeatherRepository, KoinComponent {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
@ -158,7 +161,12 @@ internal class WeatherRepositoryImpl(
if (HereProvider.isAvailable(context)) {
providers.add(WeatherProviderInfo(HereProvider.Id, context.getString(R.string.provider_here)))
}
return flowOf(providers)
val pluginProviders = pluginRepository.findMany(type = PluginType.Weather, enabled = true)
return pluginProviders.map {
providers + it.map {
WeatherProviderInfo(it.authority, it.label)
}
}
}
}
@ -173,7 +181,7 @@ class WeatherUpdateWorker(val context: Context, params: WorkerParameters) :
val settingsData = settings.data.first()
val provider = WeatherProvider.getInstance(settingsData.provider)
val updateInterval = provider.updateInterval
val updateInterval = provider.getUpdateInterval()
val lastUpdate = settingsData.lastUpdate
if (lastUpdate + updateInterval > System.currentTimeMillis()) {

View File

@ -0,0 +1,327 @@
package de.mm20.launcher2.weather.plugin
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.CancellationSignal
import android.util.Log
import androidx.core.database.getDoubleOrNull
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.config.WeatherPluginConfig
import de.mm20.launcher2.plugin.contracts.PluginContract
import de.mm20.launcher2.plugin.contracts.WeatherPluginContract
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.WeatherLocation
import de.mm20.launcher2.weather.WeatherProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.util.Locale
import kotlin.coroutines.resume
internal class PluginWeatherProvider(
private val context: Context,
private val pluginAuthority: String,
) : WeatherProvider {
override suspend fun getWeatherData(location: WeatherLocation): List<Forecast>? {
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.path(WeatherPluginContract.Paths.Forecasts).apply {
if (location is WeatherLocation.LatLon) {
appendQueryParameter(
WeatherPluginContract.ForecastParams.Lat,
location.lat.toString()
)
appendQueryParameter(
WeatherPluginContract.ForecastParams.Lon,
location.lon.toString()
)
} else if (location is WeatherLocation.Id) {
appendQueryParameter(
WeatherPluginContract.ForecastParams.Id,
location.locationId
)
}
}
.appendQueryParameter(WeatherPluginContract.ForecastParams.LocationName, location.name)
.appendQueryParameter(WeatherPluginContract.ForecastParams.Language, getLang())
.build()
return getWeatherData(uri)
}
override suspend fun getWeatherData(lat: Double, lon: Double): List<Forecast>? {
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.appendQueryParameter(WeatherPluginContract.ForecastParams.Lat, lat.toString())
.appendQueryParameter(WeatherPluginContract.ForecastParams.Lon, lon.toString())
.appendQueryParameter(WeatherPluginContract.ForecastParams.Language, getLang())
.build()
return getWeatherData(uri)
}
private suspend fun getWeatherData(uri: Uri): List<Forecast>? = withContext(Dispatchers.IO) {
val cancellationSignal = CancellationSignal()
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 = forecastsFromCursor(cursor) ?: emptyList()
it.resume(results)
}
}
private fun forecastsFromCursor(cursor: Cursor): List<Forecast>? {
return cursor.use {
val results = mutableListOf<Forecast>()
val timestampIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Timestamp)
.takeIf { it >= 0 } ?: return null
val createdAtIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.CreatedAt)
.takeIf { it >= 0 } ?: return null
val temperatureIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Temperature)
.takeIf { it >= 0 } ?: return null
val conditionIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Condition)
.takeIf { it >= 0 } ?: return null
val iconIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Icon).takeIf { it >= 0 }
?: return null
val locationIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Location)
.takeIf { it >= 0 }
?: return null
val providerIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Provider)
.takeIf { it >= 0 }
?: return null
val providerUrlIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.ProviderUrl)
.takeIf { it >= 0 }
val precipitationIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Precipitation)
.takeIf { it >= 0 }
val precipProbabilityIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.RainProbability)
.takeIf { it >= 0 }
val cloudsIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Clouds)
.takeIf { it >= 0 }
val humidityIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Humidity)
.takeIf { it >= 0 }
val windSpeedIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.WindSpeed)
.takeIf { it >= 0 }
val windDirectionIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.WindDirection)
.takeIf { it >= 0 }
val pressureIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Pressure)
.takeIf { it >= 0 }
val nightIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.Night)
.takeIf { it >= 0 }
val minTempIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.TemperatureMin)
.takeIf { it >= 0 }
val maxTempIndex =
cursor.getColumnIndex(WeatherPluginContract.ForecastColumns.TemperatureMax)
.takeIf { it >= 0 }
while (cursor.moveToNext()) {
results += Forecast(
timestamp = cursor.getLongOrNull(timestampIndex) ?: continue,
temperature = cursor.getDoubleOrNull(temperatureIndex) ?: continue,
updateTime = cursor.getLongOrNull(createdAtIndex) ?: continue,
condition = cursor.getStringOrNull(conditionIndex) ?: continue,
icon = getIcon(cursor.getStringOrNull(iconIndex) ?: continue),
location = cursor.getStringOrNull(locationIndex) ?: continue,
provider = cursor.getStringOrNull(providerIndex) ?: continue,
providerUrl = providerUrlIndex?.let { cursor.getStringOrNull(it) } ?: "",
clouds = cloudsIndex?.let { cursor.getIntOrNull(it) },
humidity = humidityIndex?.let { cursor.getDoubleOrNull(it) },
precipitation = precipitationIndex?.let { cursor.getDoubleOrNull(it) },
precipProbability = precipProbabilityIndex?.let { cursor.getIntOrNull(it) },
windSpeed = windSpeedIndex?.let { cursor.getDoubleOrNull(it) },
windDirection = windDirectionIndex?.let { cursor.getDoubleOrNull(it) },
pressure = pressureIndex?.let { cursor.getDoubleOrNull(it) },
night = nightIndex?.let { cursor.getIntOrNull(it) } == 1,
minTemp = minTempIndex?.let { cursor.getDoubleOrNull(it) },
maxTemp = maxTempIndex?.let { cursor.getDoubleOrNull(it) },
)
}
results
}
}
private fun getIcon(icon: String): Int {
return when (icon) {
"Clear" -> Forecast.CLEAR
"Cloudy" -> Forecast.CLOUDY
"Cold" -> Forecast.COLD
"Drizzle" -> Forecast.DRIZZLE
"Haze" -> Forecast.HAZE
"Fog" -> Forecast.FOG
"Hail" -> Forecast.HAIL
"HeavyThunderstorm" -> Forecast.HEAVY_THUNDERSTORM
"HeavyThunderstormWithRain" -> Forecast.HEAVY_THUNDERSTORM_WITH_RAIN
"Hot" -> Forecast.HOT
"MostlyCloudy" -> Forecast.MOSTLY_CLOUDY
"PartlyCloudy" -> Forecast.PARTLY_CLOUDY
"Showers" -> Forecast.SHOWERS
"Sleet" -> Forecast.SLEET
"Snow" -> Forecast.SNOW
"Storm" -> Forecast.STORM
"Thunderstorm" -> Forecast.THUNDERSTORM
"ThunderstormWithRain" -> Forecast.THUNDERSTORM_WITH_RAIN
"Wind" -> Forecast.WIND
"BrokenClouds" -> Forecast.BROKEN_CLOUDS
else -> Forecast.NONE
}
}
private fun getLang(): String {
return Locale.getDefault().language
}
override suspend fun findLocation(query: String): List<WeatherLocation> = withContext(Dispatchers.IO) {
val cancellationSignal = CancellationSignal()
return@withContext suspendCancellableCoroutine {
it.invokeOnCancellation {
cancellationSignal.cancel()
}
val uri = Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.path(WeatherPluginContract.Paths.Locations)
.appendQueryParameter(WeatherPluginContract.LocationParams.Query, query)
.appendQueryParameter(WeatherPluginContract.LocationParams.Language, getLang())
.build()
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 = locationsFromCursor(cursor) ?: emptyList()
it.resume(results)
}
}
private fun locationsFromCursor(cursor: Cursor): List<WeatherLocation> {
return cursor.use {
val results = mutableListOf<WeatherLocation>()
val nameIndex =
cursor.getColumnIndex(WeatherPluginContract.LocationColumns.Name)
.takeIf { it >= 0 } ?: return emptyList()
val latIndex =
cursor.getColumnIndex(WeatherPluginContract.LocationColumns.Lat)
.takeIf { it >= 0 }
val lonIndex =
cursor.getColumnIndex(WeatherPluginContract.LocationColumns.Lon)
.takeIf { it >= 0 }
val locationIdIndex =
cursor.getColumnIndex(WeatherPluginContract.LocationColumns.Id)
.takeIf { it >= 0 }
while (cursor.moveToNext()) {
val lat = latIndex?.let { cursor.getDoubleOrNull(it) }
val lon = lonIndex?.let { cursor.getDoubleOrNull(it) }
val locationId = locationIdIndex?.let { cursor.getStringOrNull(it) }
val name = cursor.getStringOrNull(nameIndex) ?: continue
if (lat != null && lon != null) {
results += WeatherLocation.LatLon(lat = lat, lon = lon, name = name)
} else if (locationId != null) {
results += WeatherLocation.Id(locationId, name)
}
}
results
}
}
override suspend fun getUpdateInterval(): Long {
return getPluginConfig()?.minUpdateInterval ?: super.getUpdateInterval()
}
private fun getPluginConfig(): WeatherPluginConfig? {
val configBundle = try {
context.contentResolver.call(
Uri.Builder()
.scheme("content")
.authority(pluginAuthority)
.build(),
PluginContract.Methods.GetConfig,
null,
null
) ?: return null
} catch (e: Exception) {
Log.e("PluginWeatherProvider", "Plugin $pluginAuthority threw exception", e)
CrashReporter.logException(e)
return null
}
return WeatherPluginConfig(configBundle)
}
}

View File

@ -53,7 +53,7 @@ abstract class WeatherProvider(
checkPermissionOrThrow(context)
when {
uri.pathSegments.size == 1 && uri.pathSegments.first() == WeatherPluginContract.Paths.Weather -> {
uri.pathSegments.size == 1 && uri.pathSegments.first() == WeatherPluginContract.Paths.Forecasts -> {
val lat = uri.getQueryParameter(WeatherPluginContract.ForecastParams.Lat)
?.toDoubleOrNull()
val lon = uri.getQueryParameter(WeatherPluginContract.ForecastParams.Lon)