From 211f30170ccf6ece10f5459ed0c5862bec1c7a9e Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Fri, 22 Dec 2023 01:01:59 +0100 Subject: [PATCH] Implement plugin weather provider --- .../plugin/config/WeatherPluginConfig.kt | 22 +- .../plugin/contracts/WeatherPluginContract.kt | 2 +- .../java/de/mm20/launcher2/weather/Module.kt | 5 +- .../mm20/launcher2/weather/WeatherProvider.kt | 5 +- .../launcher2/weather/WeatherRepository.kt | 12 +- .../weather/plugin/PluginWeatherProvider.kt | 327 ++++++++++++++++++ .../launcher2/sdk/weather/WeatherProvider.kt | 2 +- 7 files changed, 366 insertions(+), 9 deletions(-) create mode 100644 data/weather/src/main/java/de/mm20/launcher2/weather/plugin/PluginWeatherProvider.kt 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 index 6a3c459c..1e77d302 100644 --- 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 @@ -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, -) \ No newline at end of file +) { + + 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 + ), + ) + } + } +} \ 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 index ff332c73..069e9eab 100644 --- 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 @@ -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" } diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/Module.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/Module.kt index 61ec11e7..7a2497ed 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/Module.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/Module.kt @@ -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 { WeatherRepositoryImpl(androidContext(), get(), get()) } + single { WeatherRepositoryImpl(androidContext(), get(), get(), get()) } single { WeatherSettings(androidContext()) } factory(named()) { get() } factory { (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) } } } \ No newline at end of file diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt index 6c053cf9..de151c76 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt @@ -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? suspend fun getWeatherData(lat: Double, lon: Double): List? diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt index 5cec348a..32f78841 100644 --- a/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/WeatherRepository.kt @@ -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()) { diff --git a/data/weather/src/main/java/de/mm20/launcher2/weather/plugin/PluginWeatherProvider.kt b/data/weather/src/main/java/de/mm20/launcher2/weather/plugin/PluginWeatherProvider.kt new file mode 100644 index 00000000..48d0354d --- /dev/null +++ b/data/weather/src/main/java/de/mm20/launcher2/weather/plugin/PluginWeatherProvider.kt @@ -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? { + 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? { + 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? = 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? { + return cursor.use { + val results = mutableListOf() + + 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 = 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 { + return cursor.use { + val results = mutableListOf() + + 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) + } +} \ No newline at end of file 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 index f47284c0..4c7de252 100644 --- 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 @@ -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)