Reorganize and group modules

This commit is contained in:
MM20
2022-12-13 17:37:26 +01:00
parent bac24baad2
commit 3f8880a90a
995 changed files with 501 additions and 298 deletions
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
</manifest>
@@ -0,0 +1,121 @@
package de.mm20.launcher2.weather
import kotlin.math.abs
data class DailyForecast(
val timestamp: Long,
val minTemp: Double,
val maxTemp: Double,
val hourlyForecasts: List<Forecast>,
val icon: Int = getAverageIcon(hourlyForecasts)
) {
companion object {
private fun getAverageIcon(forecasts: List<Forecast>): Int {
var clear = 0f
var clouds = 0f
var rain = 0f
var thunder = 0f
var wind = 0f
var snow = 0f
for (f in forecasts) {
when (f.icon) {
Forecast.SHOWERS, Forecast.HAIL -> {
rain += 2f
clouds += 1f
}
Forecast.THUNDERSTORM_WITH_RAIN -> {
rain += 2f
thunder += 5f
clouds += 1f
}
Forecast.THUNDERSTORM -> {
thunder += 5f
clouds += 1f
}
Forecast.BROKEN_CLOUDS, Forecast.MOSTLY_CLOUDY -> {
clouds += 0.7f
clear += 0.3f
}
Forecast.PARTLY_CLOUDY -> {
clouds += 0.3f
clear += 0.7f
}
Forecast.CLOUDY -> {
clouds += 1f
}
Forecast.SNOW -> {
snow += 2f
clouds += 1f
}
Forecast.DRIZZLE -> {
rain += 1f
}
Forecast.HEAVY_THUNDERSTORM -> {
thunder += 8f
}
Forecast.HEAVY_THUNDERSTORM_WITH_RAIN -> {
thunder += 8f
clouds += 1f
}
Forecast.SLEET -> {
rain += 1f
snow += 1f
}
Forecast.STORM -> {
wind += 8f
}
Forecast.WIND -> {
wind += 5f
}
Forecast.CLEAR -> {
clear += 1f
}
}
}
val pairs = listOf(
"clear" to clear,
"clouds" to clouds,
"rain" to rain,
"thunder" to thunder,
"wind" to wind,
"snow" to snow
).sortedByDescending { it.second }
val first = pairs[0]
val second = pairs[1]
when (first.first) {
"wind" -> return if (first.second / forecasts.size > 6f) Forecast.STORM else Forecast.WIND
"thunder" -> {
val heavy = first.second / forecasts.size > 6f
val withRain = second.first == "rain"
if (heavy && withRain) return Forecast.HEAVY_THUNDERSTORM_WITH_RAIN
if (heavy && !withRain) return Forecast.HEAVY_THUNDERSTORM
if (!heavy && withRain) return Forecast.THUNDERSTORM_WITH_RAIN
return Forecast.THUNDERSTORM
}
"rain" -> {
val heavy = first.second / forecasts.size > 0.8f
val withSnow = second.first == "snow" && abs(1 - (first.second / second.second)) < 0.2
if (withSnow) return Forecast.SLEET
if (heavy) return Forecast.SHOWERS
return Forecast.DRIZZLE
}
"snow" -> {
val withRain = second.first == "rain" && abs(1 - (first.second / second.second)) < 0.2
if (withRain) return Forecast.SLEET
return Forecast.SNOW
}
else -> {
if (clouds == 0f) return Forecast.CLEAR
if (clear == 0f) return Forecast.CLOUDY
if (clouds > clear) {
if (clear > snow && clear > rain) return Forecast.MOSTLY_CLOUDY
if (clear < snow && clear < rain) return Forecast.SLEET
if (clear < snow && clear > rain) return Forecast.SNOW
if (clear > snow && clear < rain) return Forecast.DRIZZLE
}
return Forecast.PARTLY_CLOUDY
}
}
}
}
}
@@ -0,0 +1,135 @@
package de.mm20.launcher2.weather
import de.mm20.launcher2.database.entities.ForecastEntity
import de.mm20.launcher2.weather.Forecast.Companion.BROKEN_CLOUDS
import de.mm20.launcher2.weather.Forecast.Companion.CLEAR
import de.mm20.launcher2.weather.Forecast.Companion.CLOUDY
import de.mm20.launcher2.weather.Forecast.Companion.COLD
import de.mm20.launcher2.weather.Forecast.Companion.DRIZZLE
import de.mm20.launcher2.weather.Forecast.Companion.FOG
import de.mm20.launcher2.weather.Forecast.Companion.HAIL
import de.mm20.launcher2.weather.Forecast.Companion.HAZE
import de.mm20.launcher2.weather.Forecast.Companion.HEAVY_THUNDERSTORM
import de.mm20.launcher2.weather.Forecast.Companion.HEAVY_THUNDERSTORM_WITH_RAIN
import de.mm20.launcher2.weather.Forecast.Companion.HOT
import de.mm20.launcher2.weather.Forecast.Companion.MOSTLY_CLOUDY
import de.mm20.launcher2.weather.Forecast.Companion.NONE
import de.mm20.launcher2.weather.Forecast.Companion.PARTLY_CLOUDY
import de.mm20.launcher2.weather.Forecast.Companion.SHOWERS
import de.mm20.launcher2.weather.Forecast.Companion.SLEET
import de.mm20.launcher2.weather.Forecast.Companion.SNOW
import de.mm20.launcher2.weather.Forecast.Companion.STORM
import de.mm20.launcher2.weather.Forecast.Companion.THUNDERSTORM
import de.mm20.launcher2.weather.Forecast.Companion.THUNDERSTORM_WITH_RAIN
import de.mm20.launcher2.weather.Forecast.Companion.WIND
data class Forecast(
val timestamp: Long,
/** The temperature, in Kelvin **/
val temperature: Double,
/** The min temperature, in Kelvin **/
val minTemp: Double = -1.0,
/** The max temperature, in Kelvin **/
val maxTemp: Double = -1.0,
/** The temperature, in hPa **/
val pressure: Double = -1.0,
/** The temperature, in percent **/
val humidity: Double = -1.0,
/** The icon, one of [NONE], [CLEAR], [CLOUDY], [COLD], [DRIZZLE], [HAZE], [FOG],
* [HAIL], [HEAVY_THUNDERSTORM], [HEAVY_THUNDERSTORM_WITH_RAIN], [HOT], [MOSTLY_CLOUDY],
* [PARTLY_CLOUDY], [SHOWERS], [SLEET], [SNOW], [STORM], [THUNDERSTORM],
* [THUNDERSTORM_WITH_RAIN], [WIND], [BROKEN_CLOUDS]**/
val icon: Int,
/** A text describing the current weather condition **/
val condition: String,
/** The clouds, percentage **/
val clouds: Int = -1,
/** Wind speed, in m/s **/
val windSpeed: Double = -1.0,
/** wind direction, in degrees **/
val windDirection: Double = -1.0,
/** rain, in mm per hour **/
val precipitation: Double = -1.0,
/** whether this forecast is during night time (whether a moon icon should be used instead of sun) **/
val night: Boolean = false,
/** Location string **/
val location: String,
/** Provider name **/
val provider: String,
/** Url to the provider and more weather information **/
val providerUrl: String = "",
/** Rain probability, in percent [0..100]. -1 if not available **/
val precipProbability: Int = -1,
/** Timestamp (in millis) when when this forecast was created **/
val updateTime: Long
) {
fun toDatabaseEntity(): ForecastEntity {
return ForecastEntity(
timestamp = timestamp,
clouds = clouds,
condition = condition,
humidity = humidity,
icon = icon,
location = location,
maxTemp = maxTemp,
minTemp = minTemp,
night = night,
pressure = pressure,
provider = provider,
providerUrl = providerUrl,
precipitation = precipitation,
precipProbability = precipProbability,
temperature = temperature,
updateTime = updateTime,
windDirection = windDirection,
windSpeed = windSpeed,
snow = -1.0,
snowProbability = -1
)
}
constructor(entity: ForecastEntity) : this(
timestamp = entity.timestamp,
clouds = entity.clouds,
condition = entity.condition,
humidity = entity.humidity,
icon = entity.icon,
location = entity.location,
maxTemp = entity.maxTemp,
minTemp = entity.minTemp,
night = entity.night,
pressure = entity.pressure,
provider = entity.provider,
providerUrl = entity.providerUrl,
precipitation = entity.precipitation,
precipProbability = entity.precipProbability,
temperature = entity.temperature,
updateTime = entity.updateTime,
windDirection = entity.windDirection,
windSpeed = entity.windSpeed
)
companion object {
const val NONE = -1
const val CLEAR = 0
const val CLOUDY = 1
const val COLD = 2
const val DRIZZLE = 3
const val HAZE = 4
const val FOG = 5
const val HAIL = 6
const val HEAVY_THUNDERSTORM = 7
const val HEAVY_THUNDERSTORM_WITH_RAIN = 8
const val HOT = 9
const val MOSTLY_CLOUDY = 10
const val PARTLY_CLOUDY = 11
const val SHOWERS = 12
const val SLEET = 13
const val SNOW = 14
const val STORM = 15
const val THUNDERSTORM = 16
const val THUNDERSTORM_WITH_RAIN = 17
const val WIND = 18
const val BROKEN_CLOUDS = 19
}
}
@@ -0,0 +1,162 @@
package de.mm20.launcher2.weather
import android.location.Geocoder
import androidx.core.content.edit
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.formatToString
import de.mm20.launcher2.ktx.getDouble
import de.mm20.launcher2.ktx.putDouble
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
/**
* A WeatherProvider that uses lat/lon locations only (instead of provider specific location IDs)
*/
abstract class LatLonWeatherProvider : WeatherProvider<LatLonWeatherLocation>() {
override suspend fun lookupLocation(query: String): List<LatLonWeatherLocation> {
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(
LatLonWeatherLocation(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) {
CrashReporter.logException(e)
emptyList()
}
} ?: emptyList()
return locations.mapNotNull {
LatLonWeatherLocation(
lat = it.latitude,
lon = it.longitude,
name = it.formatToString()
)
}
}
override suspend fun loadWeatherData(
lat: Double,
lon: Double
): WeatherUpdateResult<LatLonWeatherLocation>? {
return try {
val locationName = getLocationName(lat, lon)
loadWeatherData(
LatLonWeatherLocation(
name = locationName,
lat = lat,
lon = lon
)
)
} catch (e: IOException) {
CrashReporter.logException(e)
null
}
}
private suspend fun getLocationName(lat: Double, lon: Double): String {
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) {
CrashReporter.logException(e)
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"
}
override fun setLocation(location: WeatherLocation?) {
location as LatLonWeatherLocation?
preferences.edit {
if (location == null) {
remove(LAT)
remove(LON)
remove(LOCATION_NAME)
} else {
putDouble(LAT, location.lat)
putDouble(LON, location.lon)
putString(LOCATION_NAME, location.name)
}
}
}
override fun getLocation(): LatLonWeatherLocation? {
val lat = preferences.getDouble(LAT) ?: return null
val lon = preferences.getDouble(LON) ?: return null
val name = preferences.getString(LOCATION_NAME, null) ?: return null
return LatLonWeatherLocation(
name = name,
lat = lat,
lon = lon
)
}
override fun saveLastLocation(location: LatLonWeatherLocation) {
preferences.edit {
putDouble(LAST_LAT, location.lat)
putDouble(LAST_LON, location.lon)
putString(LAST_LOCATION_NAME, location.name)
}
}
override fun getLastLocation(): LatLonWeatherLocation? {
val lat = preferences.getDouble(LAST_LAT) ?: return null
val lon = preferences.getDouble(LAST_LON) ?: return null
val name = preferences.getString(LAST_LOCATION_NAME, null) ?: return null
return LatLonWeatherLocation(
name = name,
lat = lat,
lon = lon
)
}
companion object {
private const val LAT = "lat"
private const val LON = "lon"
private const val LOCATION_NAME = "location_name"
private const val LAST_LAT = "last_lat"
private const val LAST_LON = "last_lon"
private const val LAST_LOCATION_NAME = "last_location_name"
}
}
data class LatLonWeatherLocation(
override val name: String,
val lat: Double,
val lon: Double
) : WeatherLocation
@@ -0,0 +1,21 @@
package de.mm20.launcher2.weather
import de.mm20.launcher2.preferences.Settings.WeatherSettings
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 org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val weatherModule = module {
single<WeatherRepository> { WeatherRepositoryImpl(androidContext(), get(), get()) }
factory { (selectedProvider: WeatherSettings.WeatherProvider) ->
when (selectedProvider) {
WeatherSettings.WeatherProvider.OpenWeatherMap -> OpenWeatherMapProvider(androidContext())
WeatherSettings.WeatherProvider.Here -> HereProvider(androidContext())
WeatherSettings.WeatherProvider.BrightSky -> BrightskyProvider(androidContext())
else -> MetNoProvider(androidContext())
}
}
}
@@ -0,0 +1,47 @@
package de.mm20.launcher2.weather
data class Weather2(
val timestamp: Long,
val temperature: Double,
val minTemp: Double,
val maxTemp: Double,
val pressure: Double,
val humidity: Double,
val icon: Int,
val condition: String,
val clouds: String,
val windSpeed: Double,
val windDirection: Double,
val rain: Double,
val snow: Double,
val night: Boolean,
val location: String,
val provider: String,
val providerUrl: String
) {
companion object {
const val NONE = -1
const val CLEAR = 0
const val CLOUDY = 1
const val COLD = 2
const val DRIZZLE = 3
const val HAZE = 4
const val FOG = 5
const val HAIL = 6
const val HEAVY_THUNDERSTORM = 7
const val HEAVY_THUNDERSTORM_WITH_RAIN = 8
const val HOT = 9
const val MOSTLY_CLOUDY = 10
const val PARTLY_CLOUDY = 11
const val SHOWERS = 12
const val SLEET = 13
const val SNOW = 14
const val STORM = 15
const val THUNDERSTORM = 16
const val THUNDERSTORM_WITH_RAIN = 17
const val WIND = 18
const val BROKEN_CLOUDS = 19
}
}
@@ -0,0 +1,5 @@
package de.mm20.launcher2.weather
interface WeatherLocation {
val name: String
}
@@ -0,0 +1,105 @@
package de.mm20.launcher2.weather
import android.Manifest
import android.content.Context
import android.content.SharedPreferences
import android.location.LocationManager
import androidx.core.content.edit
import androidx.core.content.getSystemService
import de.mm20.launcher2.ktx.checkPermission
abstract class WeatherProvider<T : WeatherLocation> {
internal abstract val context: Context
internal abstract val preferences: SharedPreferences
var autoLocation: Boolean
get() {
return preferences.getBoolean(AUTO_LOCATION, true)
}
set(value) {
preferences.edit {
putBoolean(AUTO_LOCATION, value)
}
}
suspend fun fetchNewWeatherData(): List<Forecast>? {
val result: WeatherUpdateResult<T>
if (autoLocation) {
if (context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) {
val lm = context.getSystemService<LocationManager>()!!
val location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
if (location != null) {
result = loadWeatherData(location.latitude, location.longitude) ?: return null
} else {
val lastLocation = getLastLocation() ?: return null
result = loadWeatherData(lastLocation) ?: return null
}
} else {
val lastLocation = getLastLocation() ?: return null
result = loadWeatherData(lastLocation) ?: return null
}
} else {
val setLocation = getLocation() ?: return null
result = loadWeatherData(setLocation) ?: return null
}
saveLastLocation(result.location)
setLastUpdate(System.currentTimeMillis())
return result.forecasts
}
internal abstract suspend fun loadWeatherData(location: T): WeatherUpdateResult<T>?
internal abstract suspend fun loadWeatherData(lat: Double, lon: Double): WeatherUpdateResult<T>?
abstract fun isUpdateRequired(): Boolean
fun getLastUpdate(): Long {
return preferences.getLong(LAST_UPDATE, 0)
}
private fun setLastUpdate(time: Long) {
preferences.edit {
putLong(LAST_UPDATE, time)
}
}
/**
* Lookup a location based on a string query.
* @param query the location to lookup
* @return a list of locations
*/
abstract suspend fun lookupLocation(query: String): List<T>
/**
* @param location must be of type T
*/
abstract fun setLocation(location: WeatherLocation?)
abstract fun getLocation(): T?
abstract fun isAvailable(): Boolean
abstract val name: String
abstract fun getLastLocation(): T?
abstract fun saveLastLocation(location: T)
fun resetLastUpdate() {
preferences.edit {
putLong(LAST_UPDATE, 0L)
}
}
companion object {
private const val LAST_UPDATE = "last_update"
private const val AUTO_LOCATION = "auto_location"
}
}
data class WeatherUpdateResult<T : WeatherLocation>(
val forecasts: List<Forecast>,
val location: T
)
@@ -0,0 +1,249 @@
package de.mm20.launcher2.weather
import android.content.Context
import android.util.Log
import androidx.work.*
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.WeatherSettings
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 kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import org.koin.core.parameter.parametersOf
import java.util.*
import java.util.concurrent.TimeUnit
interface WeatherRepository {
val forecasts: Flow<List<DailyForecast>>
suspend fun lookupLocation(query: String): List<WeatherLocation>
val lastLocation: Flow<WeatherLocation?>
val location: Flow<WeatherLocation?>
val autoLocation: Flow<Boolean>
fun setLocation(location: WeatherLocation)
fun setAutoLocation(autoLocation: Boolean)
fun setLastLocation(lastLocation: WeatherLocation?)
fun getAvailableProviders(): List<WeatherSettings.WeatherProvider>
fun selectProvider(provider: WeatherSettings.WeatherProvider)
val selectedProvider: Flow<WeatherSettings.WeatherProvider>
fun clearForecasts()
}
internal class WeatherRepositoryImpl(
private val context: Context,
private val database: AppDatabase,
private val dataStore: LauncherDataStore,
) : WeatherRepository, KoinComponent {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private var provider: WeatherProvider<out WeatherLocation>
private val permissionsManager: PermissionsManager by inject()
private val hasLocationPermission = permissionsManager.hasPermission(PermissionGroup.Location)
override val selectedProvider = dataStore.data.map { it.weather.provider }
override val forecasts: Flow<List<DailyForecast>>
get() = database.weatherDao().getForecasts()
.map { it.map { Forecast(it) } }
.map {
groupForecastsPerDay(it)
}
override val lastLocation = MutableStateFlow<WeatherLocation?>(null)
override val location = MutableStateFlow<WeatherLocation?>(null)
override val autoLocation = MutableStateFlow(false)
override fun setLocation(location: WeatherLocation) {
provider.setLocation(location)
this.location.value = location
provider.resetLastUpdate()
requestUpdate()
}
override fun setAutoLocation(autoLocation: Boolean) {
provider.autoLocation = autoLocation
this.autoLocation.value = autoLocation
provider.resetLastUpdate()
requestUpdate()
}
override fun setLastLocation(lastLocation: WeatherLocation?) {
this.lastLocation.value = lastLocation
}
override suspend fun lookupLocation(query: String): List<WeatherLocation> {
return provider.lookupLocation(query)
}
override fun selectProvider(provider: WeatherSettings.WeatherProvider) {
scope.launch {
dataStore.updateData {
it.toBuilder()
.setWeather(
it.weather.toBuilder()
.setProvider(provider)
)
.build()
}
}
}
init {
val weatherRequest =
PeriodicWorkRequest.Builder(WeatherUpdateWorker::class.java, 60, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weather",
ExistingPeriodicWorkPolicy.KEEP, weatherRequest
)
provider = runBlocking {
val selectedProvider = selectedProvider.first()
get { parametersOf(selectedProvider) }
}
scope.launch {
var providerSetting: WeatherSettings.WeatherProvider? = null
selectedProvider.collectLatest {
if (it != providerSetting) {
provider = get { parametersOf(it) }
location.value = provider.getLocation()
lastLocation.value = provider.getLastLocation()
autoLocation.value = provider.autoLocation
// Force weather data update but only if provider has changed; not during
// initialization
if (providerSetting != null) {
provider.resetLastUpdate()
requestUpdate()
}
providerSetting = it
}
}
hasLocationPermission.collectLatest {
if (it) requestUpdate()
}
}
}
private fun groupForecastsPerDay(forecasts: List<Forecast>): List<DailyForecast> {
val dailyForecasts = mutableListOf<DailyForecast>()
val calendar = Calendar.getInstance()
var currentDay = 0
var currentDayForecasts: MutableList<Forecast> = mutableListOf()
for (fc in forecasts) {
calendar.timeInMillis = fc.timestamp
if (currentDay != calendar.get(Calendar.DAY_OF_YEAR)) {
if (currentDayForecasts.isNotEmpty()) {
dailyForecasts.add(
DailyForecast(
timestamp = currentDayForecasts.first().timestamp,
minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature
?: 0.0,
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature
?: 0.0,
hourlyForecasts = currentDayForecasts
)
)
currentDayForecasts = mutableListOf()
}
currentDay = calendar.get(Calendar.DAY_OF_YEAR)
}
currentDayForecasts.add(fc)
}
if (currentDayForecasts.isNotEmpty()) {
dailyForecasts.add(
DailyForecast(
timestamp = currentDayForecasts.first().timestamp,
minTemp = currentDayForecasts.minByOrNull { it.temperature }?.temperature
?: 0.0,
maxTemp = currentDayForecasts.maxByOrNull { it.temperature }?.temperature
?: 0.0,
hourlyForecasts = currentDayForecasts
)
)
}
return dailyForecasts
}
private fun requestUpdate() {
val weatherRequest = OneTimeWorkRequest.Builder(WeatherUpdateWorker::class.java)
.addTag("weather")
.build()
WorkManager.getInstance(context).enqueue(weatherRequest)
}
override fun clearForecasts() {
scope.launch {
withContext(Dispatchers.IO) {
database.weatherDao().deleteAll()
provider.resetLastUpdate()
}
}
}
override fun getAvailableProviders(): List<WeatherSettings.WeatherProvider> {
val providers = mutableListOf<WeatherSettings.WeatherProvider>()
if (BrightskyProvider(context).isAvailable()) {
providers.add(WeatherSettings.WeatherProvider.BrightSky)
}
if (OpenWeatherMapProvider(context).isAvailable()) {
providers.add(WeatherSettings.WeatherProvider.OpenWeatherMap)
}
if (MetNoProvider(context).isAvailable()) {
providers.add(WeatherSettings.WeatherProvider.MetNo)
}
if (HereProvider(context).isAvailable()) {
providers.add(WeatherSettings.WeatherProvider.Here)
}
return providers
}
}
class WeatherUpdateWorker(val context: Context, params: WorkerParameters) :
CoroutineWorker(context, params), KoinComponent {
val repository: WeatherRepository by inject()
override suspend fun doWork(): Result {
Log.d("MM20", "Requesting weather data")
val providerPref = repository.selectedProvider.first()
val provider: WeatherProvider<out WeatherLocation> = get { parametersOf(providerPref) }
if (!provider.isAvailable()) {
Log.d("MM20", "Weather provider is not available")
return Result.failure()
}
if (!provider.isUpdateRequired()) {
Log.d("MM20", "No weather update required")
return Result.failure()
}
val weatherData = provider.fetchNewWeatherData()
return if (weatherData == null) {
Log.d("MM20", "Weather update failed")
Result.retry()
} else {
repository.setLastLocation(provider.getLastLocation())
Log.d("MM20", "Weather update succeeded")
AppDatabase.getInstance(applicationContext).weatherDao()
.replaceAll(weatherData.map { it.toDatabaseEntity() })
Result.success()
}
}
}
@@ -0,0 +1,38 @@
package de.mm20.launcher2.weather.brightsky
import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
import retrofit2.http.Query
data class BrightSkyResult(
val weather: Array<BrightSkyResultWeather>
)
data class BrightSkyResultWeather(
val timestamp: String?,
@SerializedName("source_id") val sourceId: Int?,
@SerializedName("cloud_cover") val cloudCover: Double?,
val condition: String?,
@SerializedName("dew_point") val dewPoint: Double?,
val icon: String?,
val precipitation: Double?,
@SerializedName("pressure_msl") val pressureMsl: Double?,
@SerializedName("relative_humidity") val relativeHumidity: Double?,
val sunshine: Double?,
val temperature: Double?,
val visibility: Double?,
@SerializedName("wind_direction") val windDirection: Double?,
@SerializedName("wind_speed") val windSpeed: Double?,
@SerializedName("wind_gust_direction") val windGustDirection: Double?,
@SerializedName("wind_gust_speed") val windGustSpeed: Double?,
)
interface BrightSkyApi {
@GET("/weather?units=si")
suspend fun weather(
@Query("date") date: String,
@Query("last_date") lastDate: String,
@Query("lat") lat: Double,
@Query("lon") lon: Double,
): BrightSkyResult
}
@@ -0,0 +1,129 @@
package de.mm20.launcher2.weather.brightsky
import android.content.Context
import android.content.SharedPreferences
import android.icu.text.SimpleDateFormat
import android.icu.util.Calendar
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.weather.*
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import java.lang.Exception
import kotlin.math.roundToInt
class BrightskyProvider(
override val context: Context
) : LatLonWeatherProvider() {
val apiClient by lazy {
val retrofit = Retrofit.Builder()
.baseUrl("https://api.brightsky.dev/")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create<BrightSkyApi>()
}
override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult<LatLonWeatherLocation>? {
val result = runCatching {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")
val date = Calendar.getInstance()
date.timeInMillis -= 1000 * 60 * 30
val startDate = format.format(date.timeInMillis)
date.timeInMillis += 1000 * 60 * 60 * 24 * 14
val endDate = format.format(date.timeInMillis)
apiClient.weather(
date = startDate,
lastDate = endDate,
lat = location.lat,
lon = location.lon,
)
}.getOrElse {
CrashReporter.logException(Exception(it))
return null
}
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX")
val forecasts = mutableListOf<Forecast>()
val updateTime = System.currentTimeMillis()
for (weather in result.weather) {
forecasts.add(
Forecast(
timestamp = format.parse(weather.timestamp)?.time ?: continue,
clouds = weather.cloudCover?.roundToInt() ?: -1,
condition = getCondition(weather.icon ?: continue) ?: continue,
humidity = weather.relativeHumidity ?: -1.0,
icon = getIcon(weather.icon) ?: continue,
location = location.name,
maxTemp = weather.temperature ?: continue,
minTemp = weather.temperature,
night = (weather.sunshine ?: 100.0).roundToInt() == 0,
pressure = weather.pressureMsl ?: -1.0,
provider = "Deutscher Wetterdienst",
providerUrl = "https://www.dwd.de/",
precipitation = weather.precipitation ?: -1.0,
precipProbability = -1,
temperature = weather.temperature,
updateTime = updateTime,
windDirection = weather.windDirection ?: -1.0,
windSpeed = weather.windSpeed ?: -1.0
)
)
}
return WeatherUpdateResult(
forecasts, location
)
}
private fun getIcon(icon: String): Int? {
return when (icon) {
"clear-day", "clear-night" -> Forecast.CLEAR
"partly-cloudy-day", "partly-cloudy-night" -> Forecast.PARTLY_CLOUDY
"cloudy" -> Forecast.CLOUDY
"fog" -> Forecast.FOG
"wind" -> Forecast.WIND
"rain" -> Forecast.SHOWERS
"sleet" -> Forecast.SLEET
"snow" -> Forecast.SNOW
"hail" -> Forecast.HAIL
"thunderstorm" -> Forecast.THUNDERSTORM
else -> null
}
}
private fun getCondition(icon: String): String? {
val resId = when (icon) {
"clear-day", "clear-night" -> R.string.weather_condition_clearsky
"partly-cloudy-day", "partly-cloudy-night" -> R.string.weather_condition_partlycloudy
"cloudy" -> R.string.weather_condition_cloudy
"fog" -> R.string.weather_condition_fog
"wind" -> R.string.weather_details_wind
"rain" -> R.string.weather_condition_rain
"sleet" -> R.string.weather_condition_sleet
"snow" -> R.string.weather_condition_snow
"hail" -> R.string.weather_condition_hail
"thunderstorm" -> R.string.weather_condition_thunder
else -> return null
}
return context.getString(resId)
}
override val preferences: SharedPreferences
get() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
override fun isUpdateRequired(): Boolean {
return getLastUpdate() + 3600000 < System.currentTimeMillis()
}
override fun isAvailable(): Boolean {
return true
}
override val name: String
get() = context.getString(R.string.provider_brightsky)
companion object {
const val PREFS = "bright_sky"
}
}
@@ -0,0 +1,52 @@
package de.mm20.launcher2.weather.here
import retrofit2.http.GET
import retrofit2.http.Query
data class HereGeocodeResult(
val Response: HereGeocodeResultResponse
)
data class HereGeocodeResultResponse(
val View: Array<HereGeocodeResultResponseView>?
)
data class HereGeocodeResultResponseView(
val Result: Array<HereGeocodeResultResponseViewResult>?
)
data class HereGeocodeResultResponseViewResult(
val Location: HereGeocodeResultResponseViewResultLocation?
)
data class HereGeocodeResultResponseViewResultLocation(
val LocationId: String?,
val LocationType: String?,
val DisplayPosition: HereGeocodeResultResponseViewResultLocationPosition?,
val Address: HereGeocodeResultResponseViewResultLocationAddress?
)
data class HereGeocodeResultResponseViewResultLocationPosition(
val Latitude: Double?,
val Longitude: Double?
)
data class HereGeocodeResultResponseViewResultLocationAddress(
val Label: String?,
val Country: String?,
val State: String?,
val County: String?,
val City: String?,
val District: String?,
val Street: String?,
val HouseNumber: String?,
val PostalCode: String?,
)
interface HereGeocodeApi {
@GET("geocode.json")
suspend fun geocode(
@Query("apiKey") apiKey: String,
@Query("searchtext") searchtext: String
): HereGeocodeResult
}
@@ -0,0 +1,332 @@
package de.mm20.launcher2.weather.here
import android.content.Context
import android.content.SharedPreferences
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.weather.*
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
class HereProvider(override val context: Context) : LatLonWeatherProvider() {
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl("https://weather.ls.hereapi.com/weather/1.0/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
private val hereWeatherService by lazy {
retrofit.create<HereWeatherApi>()
}
override val preferences: SharedPreferences
get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult<LatLonWeatherLocation>? {
return loadWeatherData(location.lat, location.lon)
}
override suspend fun loadWeatherData(
lat: Double,
lon: Double
): WeatherUpdateResult<LatLonWeatherLocation>? {
val updateTime = System.currentTimeMillis()
val lang = Locale.getDefault().language
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ROOT)
val forecastList = mutableListOf<Forecast>()
try {
val apiKey = getApiKey() ?: return null
val response = hereWeatherService.report(
apiKey = apiKey,
language = lang,
latitude = lat,
longitude = lon
)
val forecastLocation = response.hourlyForecasts?.forecastLocation ?: return null
val forecasts = forecastLocation.forecast ?: return null
val location = forecastLocation.city ?: return null
for (forecast in forecasts) {
val timestamp = try {
dateFormat.parse(forecast.utcTime ?: continue)?.time ?: continue
} catch (e: ParseException) {
CrashReporter.logException(e)
return null
}
// We don't want old weather data
if (timestamp + 1000 * 60 * 30 < System.currentTimeMillis()) continue
val condition = when {
!forecast.precipitationDesc.isNullOrEmpty() -> forecast.precipitationDesc
!forecast.skyDescription.isNullOrEmpty() -> forecast.skyDescription
!forecast.temperatureDesc.isNullOrEmpty() -> forecast.temperatureDesc
else -> forecast.description ?: continue
}
val humidity = forecast.humidity?.toIntOrNull() ?: 0
val icon = getIcon(forecast.iconName ?: continue)
val night = forecast.daylight == "N"
val rain = forecast.rainFall?.toDoubleOrNull() ?: 0.0
val rainPercent = forecast.precipitationProbability?.toIntOrNull() ?: 0
val temperature = forecast.temperature?.toDoubleOrNull()?.plus(273.15)
?: 0.0
val windDir = forecast.windDirection?.toIntOrNull() ?: 0
val windSpeed = forecast.windSpeed?.toDoubleOrNull() ?: 0.0
forecastList.add(
Forecast(
timestamp = timestamp,
clouds = -1,
condition = condition,
humidity = humidity.toDouble(),
icon = icon,
location = location,
night = night,
pressure = -1.0,
provider = context.getString(R.string.provider_here),
providerUrl = "",
precipitation = rain * 10,
precipProbability = rainPercent,
temperature = temperature,
windDirection = windDir.toDouble(),
windSpeed = windSpeed,
updateTime = updateTime
)
)
}
return WeatherUpdateResult(
forecasts = forecastList,
location = LatLonWeatherLocation(
name = location,
lat = lat,
lon = lon
)
)
} catch (e: Exception) {
CrashReporter.logException(e)
return null
}
}
private fun getIcon(iconName: String): Int {
with(Forecast) {
return when (iconName) {
"sunny" -> CLEAR
"clear" -> CLEAR
"mostly_sunny" -> PARTLY_CLOUDY
"mostly_clear" -> PARTLY_CLOUDY
"passing_clounds" -> MOSTLY_CLOUDY
"more_sun_than_clouds" -> PARTLY_CLOUDY
"scattered_clouds" -> PARTLY_CLOUDY
"partly_cloudy" -> PARTLY_CLOUDY
"a_mixture_of_sun_and_clouds" -> PARTLY_CLOUDY
"increasing_cloudiness" -> MOSTLY_CLOUDY
"breaks_of_sun_late" -> MOSTLY_CLOUDY
"afternoon_clouds" -> MOSTLY_CLOUDY
"morning_clouds" -> MOSTLY_CLOUDY
"partly_sunny" -> MOSTLY_CLOUDY
"high_level_clouds" -> PARTLY_CLOUDY
"decreasing_cloudiness" -> PARTLY_CLOUDY
"clearing_skies" -> PARTLY_CLOUDY
"high_clouds" -> PARTLY_CLOUDY
"rain_early" -> SHOWERS
"heavy_rain_early" -> SHOWERS
"strong_thunderstorms" -> HEAVY_THUNDERSTORM
"severe_thunderstorms" -> HEAVY_THUNDERSTORM
"thundershowers" -> THUNDERSTORM_WITH_RAIN
"thunderstorms" -> THUNDERSTORM
"tstorms_early" -> THUNDERSTORM_WITH_RAIN
"isolated_tstorms_late" -> THUNDERSTORM
"scattered_tstorms_late" -> THUNDERSTORM
"tstorms_late" -> THUNDERSTORM_WITH_RAIN
"tstorms" -> THUNDERSTORM_WITH_RAIN
"ice_fog" -> FOG
"more_clouds_than_sun" -> MOSTLY_CLOUDY
"broken_clouds" -> MOSTLY_CLOUDY
"scattered_showers" -> SHOWERS
"a_few_showers" -> SHOWERS
"light_showers" -> SHOWERS
"passing_showers" -> SHOWERS
"rain_showers" -> SHOWERS
"showers" -> SHOWERS
"widely_scattered_tstorms" -> THUNDERSTORM
"isolated_tstorms" -> THUNDERSTORM
"a_few_tstorms" -> THUNDERSTORM
"scattered_tstorms" -> THUNDERSTORM
"hazy_sunshine" -> HAZE
"haze" -> HAZE
"smoke" -> FOG
"low_level_haze" -> HAZE
"early_fog_followed_by_sunny_skies" -> HAZE
"early_fog" -> FOG
"light_fog" -> FOG
"fog" -> FOG
"dense_fog" -> FOG
"night_haze" -> HAZE
"night_smoke" -> FOG
"night_low_level_haze" -> HAZE
"night_widely_scattered_tstorms" -> THUNDERSTORM
"night_isolated_tstorms" -> THUNDERSTORM
"night_a_few_tstorms" -> THUNDERSTORM
"night_scattered_tstorms" -> THUNDERSTORM
"night_tstorms" -> THUNDERSTORM
"night_clear" -> CLEAR
"mostly_cloudy" -> MOSTLY_CLOUDY
"cloudy" -> CLOUDY
"overcast" -> CLOUDY
"low_clouds" -> MOSTLY_CLOUDY
"hail" -> HAIL
"sleet" -> SLEET
"light_mixture_of_precip" -> SLEET
"icy_mix" -> SLEET
"mixture_of_precip" -> SLEET
"heavy_mixture_of_precip" -> SLEET
"snow_changing_to_rain" -> SLEET
"snow_changing_to_an_icy_mix" -> SLEET
"an_icy_mix_changing_to_snow" -> SLEET
"an_icy_mix_changing_to_rain" -> SLEET
"rain_changing_to_snow" -> SLEET
"rain_changing_to_an_icy_mix" -> SLEET
"light_icy_mix_early" -> SLEET
"icy_mix_early" -> SLEET
"light_icy_mix_late" -> SLEET
"icy_mix_late" -> SLEET
"snow_rain_mix" -> SLEET
"scattered_flurries" -> SNOW
"snow_flurries" -> SNOW
"light_snow_showers" -> SLEET
"snow_showers" -> SLEET
"light_snow" -> SNOW
"flurries_early" -> SNOW
"snow_showers_early" -> SLEET
"light_snow_early" -> SNOW
"flurries_late" -> SNOW
"snow_showers_late" -> SLEET
"light_snow_late" -> SNOW
"night_decreasing_cloudiness" -> PARTLY_CLOUDY
"night_clearing_skies" -> PARTLY_CLOUDY
"night_high_level_clouds" -> PARTLY_CLOUDY
"night_high_clouds" -> PARTLY_CLOUDY
"night_scattered_showers" -> SHOWERS
"night_a_few_showers" -> SHOWERS
"night_light_showers" -> SHOWERS
"night_passing_showers" -> SHOWERS
"night_rain_showers" -> SHOWERS
"night_sprinkles" -> DRIZZLE
"night_showers" -> SHOWERS
"night_mostly_clear" -> PARTLY_CLOUDY
"night_passing_clouds" -> MOSTLY_CLOUDY
"night_scattered_clouds" -> PARTLY_CLOUDY
"night_partly_cloudy" -> PARTLY_CLOUDY
"night_afternoon_clouds" -> MOSTLY_CLOUDY
"night_morning_clouds" -> MOSTLY_CLOUDY
"night_broken_clouds" -> MOSTLY_CLOUDY
"night_mostly_cloudy" -> MOSTLY_CLOUDY
"light_freezing_rain" -> HAIL
"freezing_rain" -> HAIL
"heavy_rain" -> SHOWERS
"lots_of_rain" -> SHOWERS
"tons_of_rain" -> SHOWERS
"heavy_rain_late" -> SHOWERS
"flash_floods" -> SHOWERS
"flood" -> SHOWERS
"drizzle" -> DRIZZLE
"sprinkles" -> DRIZZLE
"light_rain" -> DRIZZLE
"sprinkles_early" -> DRIZZLE
"light_rain_early" -> SHOWERS
"sprinkles_late" -> DRIZZLE
"light_rain_late" -> SHOWERS
"rain" -> SHOWERS
"numerous_showers" -> SHOWERS
"showery" -> SHOWERS
"showers_early" -> SHOWERS
"showers_late" -> SHOWERS
"rain_late" -> SHOWERS
"snow" -> SNOW
"moderate_snow" -> SNOW
"snow_early" -> SNOW
"snow_late" -> SNOW
"heavy_snow" -> SNOW
"heavy_snow_early" -> SNOW
"heavy_snow_late" -> SNOW
"tornado" -> STORM
"tropical_storm" -> STORM
"hurricane" -> STORM
"sandstorm" -> STORM
"duststorm" -> STORM
"snowstorm" -> STORM
"blizzard" -> STORM
else -> NONE
}
}
}
override fun isUpdateRequired(): Boolean {
return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis()
}
override suspend fun lookupLocation(query: String): List<LatLonWeatherLocation> {
val retrofit = Retrofit.Builder()
.baseUrl("https://geocoder.ls.hereapi.com/6.2/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val geocodeService = retrofit.create<HereGeocodeApi>()
try {
val apiKey = getApiKey() ?: return emptyList()
val response = geocodeService.geocode(apiKey, query)
return response.Response.View?.getOrNull(0)?.Result?.mapNotNull {
LatLonWeatherLocation(
name = it.Location?.Address?.Label ?: return@mapNotNull null,
lat = it.Location.DisplayPosition?.Latitude ?: return@mapNotNull null,
lon = it.Location.DisplayPosition.Longitude ?: return@mapNotNull null,
)
} ?: emptyList()
} catch (e: Exception) {
CrashReporter.logException(e)
}
return emptyList()
}
private fun getApiKey(): String? {
val resId = getApiKeyResId()
if (resId != 0) return context.getString(resId)
return null
}
override fun isAvailable(): Boolean {
return getApiKeyResId() != 0
}
override val name: String
get() = context.getString(R.string.provider_here)
private fun getApiKeyResId(): Int {
return context.resources.getIdentifier("here_key", "string", context.packageName)
}
companion object {
private const val PREFERENCES = "here"
}
}
@@ -0,0 +1,62 @@
package de.mm20.launcher2.weather.here
import retrofit2.http.GET
import retrofit2.http.Query
data class HereWeatherResult(
val hourlyForecasts: HereWeatherResultForecasts?
)
data class HereWeatherResultForecasts(
val forecastLocation: HereWeatherResultForecastsLocation?
)
data class HereWeatherResultForecastsLocation(
val forecast: Array<HereWeatherResultForecastsLocationForecast>?,
val country: String?,
val state: String?,
val city: String?,
val latitude: Double?,
val longitude: Double?,
)
data class HereWeatherResultForecastsLocationForecast(
val daylight: String?,
val description: String?,
val skyInfo: String?,
val skyDescription: String?,
val temperature: String?,
val temperatureDesc: String?,
val comfort: String?,
val humidity: String?,
val dewPoint: String?,
val precipitationProbability: String?,
val precipitationDesc: String?,
val rainFall: String?,
val snowFall: String?,
val airInfo: String?,
val airDescription: String?,
val windSpeed: String?,
val windDirection: String?,
val windDesc: String?,
val windDescShort: String?,
val visibility: String?,
val icon: String?,
val iconName: String?,
val iconLink: String?,
val dayOfWeek: String?,
val weekday: String?,
val utcTime: String?,
val localTime: String?,
val localTimeFormat: String?,
)
interface HereWeatherApi {
@GET("report.json?product=forecast_hourly")
suspend fun report(
@Query("apiKey") apiKey: String,
@Query("language") language: String,
@Query("latitude") latitude: Double,
@Query("longitude") longitude: Double
): HereWeatherResult
}
@@ -0,0 +1,255 @@
package de.mm20.launcher2.weather.metno
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.util.Base64
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.weather.*
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONException
import org.json.JSONObject
import org.shredzone.commons.suncalc.SunTimes
import java.io.IOException
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.roundToInt
class MetNoProvider(override val context: Context) : LatLonWeatherProvider() {
override val preferences: SharedPreferences
get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
private fun isNight(timestamp: Long, lat: Double, lon: Double): Boolean {
val sunTimes = SunTimes.compute().on(Date(timestamp)).at(lat, lon).execute()
if (sunTimes.isAlwaysDown) return true
if (sunTimes.isAlwaysUp) return false
val set = sunTimes.set
val rise = sunTimes.rise
if (set == null && rise != null) {
return timestamp < rise.toEpochSecond() * 1000
}
if (set != null && rise == null) {
return set.toEpochSecond() * 1000 < timestamp
}
if (set == null || rise == null) return false
if (set.toEpochSecond() < rise.toEpochSecond()) {
return (set.toEpochSecond() * 1000 < timestamp && timestamp < rise.toEpochSecond() * 1000)
}
return !(rise.toEpochSecond() * 1000 < timestamp && timestamp < set.toEpochSecond() * 1000)
}
private fun conditionForCode(code: String): String {
return context.getString(
when (code.substringBefore("_")) {
"sleetshowers" -> R.string.weather_condition_sleetshowers
"heavysleet" -> R.string.weather_condition_heavysleet
"lightrainshowersandthunder" -> R.string.weather_condition_lightrainshowersandthunder
"heavyrain" -> R.string.weather_condition_heavyrain
"lightsnowandthunder" -> R.string.weather_condition_lightsnowandthunder
"lightrain" -> R.string.weather_condition_lightrain
"lightrainshowers" -> R.string.weather_condition_lightrainshowers
"lightsnow" -> R.string.weather_condition_lightsnow
"heavysleetshowersandthunder" -> R.string.weather_condition_heavysleetshowersandthunder
"lightsnowshowers" -> R.string.weather_condition_lightsnowshowers
"lightssleetshowersandthunder" -> R.string.weather_condition_lightssleetshowersandthunder
"snowandthunder" -> R.string.weather_condition_snowandthunder
"heavysleetshowers" -> R.string.weather_condition_heavysleetshowers
"heavysnow" -> R.string.weather_condition_heavysnow
"cloudy" -> R.string.weather_condition_cloudy
"lightrainandthunder" -> R.string.weather_condition_lightrainandthunder
"snow" -> R.string.weather_condition_snow
"heavysnowshowers" -> R.string.weather_condition_heavysnowshowers
"heavyrainshowers" -> R.string.weather_condition_heavyrainshowers
"rainshowersandthunder" -> R.string.weather_condition_rainshowersandthunder
"clearsky" -> R.string.weather_condition_clearsky
"sleet" -> R.string.weather_condition_sleet
"rain" -> R.string.weather_condition_rain
"sleetandthunder" -> R.string.weather_condition_sleetandthunder
"lightssnowshowersandthunder" -> R.string.weather_condition_lightssnowshowersandthunder
"heavyrainshowersandthunder" -> R.string.weather_condition_heavyrainshowersandthunder
"fair" -> R.string.weather_condition_fair
"fog" -> R.string.weather_condition_fog
"sleetshowersandthunder" -> R.string.weather_condition_sleetshowersandthunder
"rainandthunder" -> R.string.weather_condition_rainandthunder
"lightsleet" -> R.string.weather_condition_lightsleet
"heavysleetandthunder" -> R.string.weather_condition_heavysleetandthunder
"partlycloudy" -> R.string.weather_condition_partlycloudy
"heavysnowandthunder" -> R.string.weather_condition_heavysnowandthunder
"rainshowers" -> R.string.weather_condition_rainshowers
"lightsleetandthunder" -> R.string.weather_condition_lightsleetandthunder
"heavysnowshowersandthunder" -> R.string.weather_condition_heavysnowshowersandthunder
"lightsleetshowers" -> R.string.weather_condition_lightsleetshowers
"snowshowersandthunder" -> R.string.weather_condition_snowshowersandthunder
"snowshowers" -> R.string.weather_condition_snowshowers
"heavyrainandthunder" -> R.string.weather_condition_heavyrainandthunder
else -> R.string.weather_condition_unknown
}
)
}
private fun iconForCode(code: String): Int {
return when (code.substringBefore("_")) {
"clearsky" -> Forecast.CLEAR
"fair" -> Forecast.PARTLY_CLOUDY
"partlycloudy" -> Forecast.MOSTLY_CLOUDY
"cloudy" -> Forecast.CLOUDY
"rainshowers", "rain", "lightrainshowers", "lightrain" -> Forecast.DRIZZLE
"rainshowersandthunder", "snowandthunder", "snowshowersandthunder",
"lightssnowshowersandthunder", "lightsleetandthunder",
"lightsnowandthunder" -> Forecast.THUNDERSTORM
"sleetshowers", "sleet", "lightsleetshowers", "heavysleetshowers", "lightsleet",
"heavysleet" -> Forecast.SLEET
"snowshowers", "snow", "lightsnowshowers", "heavysnowshowers", "lightsnow",
"heavysnow" -> Forecast.SNOW
"heavyrain", "heavyrainshowers" -> Forecast.SHOWERS
"heavyrainandthunder", "sleetshowersandthunder", "rainandthunder", "sleetandthunder",
"lightrainshowersandthunder", "heavyrainshowersandthunder",
"lightssleetshowersandthunder", "lightrainandthunder" -> Forecast.THUNDERSTORM_WITH_RAIN
"fog" -> Forecast.FOG
"heavysleetshowersandthunder",
"heavysleetandthunder" -> Forecast.HEAVY_THUNDERSTORM_WITH_RAIN
"heavysnowshowersandthunder", "heavysnowandthunder" -> Forecast.HEAVY_THUNDERSTORM
else -> Forecast.NONE
}
}
override fun isUpdateRequired(): Boolean {
return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis()
}
private fun getUserAgent(): String? {
val contactData = getContactInfo() ?: return null
val signature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val pi = context.packageManager.getPackageInfo(
context.packageName,
PackageManager.GET_SIGNING_CERTIFICATES
)
pi.signingInfo.apkContentsSigners.firstOrNull()
} else {
val pi = context.packageManager.getPackageInfo(
context.packageName,
PackageManager.GET_SIGNATURES
)
pi.signatures.firstOrNull()
}
val signatureHash = if (signature != null) {
val digest = MessageDigest.getInstance("SHA")
digest.update(signature.toByteArray())
Base64.encodeToString(digest.digest(), Base64.NO_WRAP)
} else "null"
return "${context.packageName}/signature:$signatureHash $contactData"
}
override fun isAvailable(): Boolean {
return getContactResId() != 0
}
private fun getContactInfo(): String? {
val resId = getContactResId().takeIf { it != 0 } ?: return null
return context.getString(resId).takeIf { it.isNotBlank() }
}
override val name: String
get() = context.getString(R.string.provider_metno)
private fun getContactResId(): Int {
return context.resources.getIdentifier("metno_contact", "string", context.packageName)
}
override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult<LatLonWeatherLocation>? {
val lastUpdate = getLastUpdate()
val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.ROOT)
val ifModifiedSince = httpDateFormat.format(Date(lastUpdate))
try {
val forecasts = mutableListOf<Forecast>()
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT)
val httpClient = OkHttpClient()
val latParam = String.format(Locale.ROOT, "%.4f", location.lat)
val lonParam = String.format(Locale.ROOT, "%.4f", location.lon)
val forecastRequest = Request.Builder()
.url("https://api.met.no/weatherapi/locationforecast/2.0/?lat=$latParam&lon=$lonParam")
.addHeader("User-Agent", getUserAgent() ?: return null)
.addHeader("If-Modified-Since", ifModifiedSince)
.get()
.build()
val response = httpClient.newCall(forecastRequest).execute()
val responseBody = response.body?.string() ?: return null
val json = JSONObject(responseBody)
val properties = json.getJSONObject("properties")
val meta = properties.getJSONObject("meta")
val updatedAt = dateFormat.parse(meta.getString("updated_at"))?.time
?: System.currentTimeMillis()
val timeseries = properties.getJSONArray("timeseries")
for (i in 0 until timeseries.length()) {
val fc = timeseries.getJSONObject(i)
val data = fc.getJSONObject("data")
val timestamp = dateFormat.parse(fc.getString("time"))?.time ?: continue
val details = data.getJSONObject("instant").getJSONObject("details")
var hours = 0
val nextHours = data.optJSONObject("next_1_hours")?.also { hours = 1 }
?: data.optJSONObject("next_6_hours")?.also { hours = 6 }
?: data.optJSONObject("next_12_hours")?.also { hours = 12 }
?: continue
val symbolCode = nextHours.optJSONObject("summary")?.getString("symbol_code")
?: continue
val precipitationAmount =
(nextHours.optJSONObject("details")?.optDouble("precipitation_amount")
?: 0.0) / hours
forecasts.add(
Forecast(
timestamp = timestamp,
temperature = details.getDouble("air_temperature") + 273.15,
updateTime = updatedAt,
clouds = details.getDouble("cloud_area_fraction").roundToInt(),
humidity = details.getDouble("relative_humidity"),
windDirection = details.getDouble("wind_from_direction"),
windSpeed = details.getDouble("wind_speed"),
pressure = details.getDouble("air_pressure_at_sea_level"),
location = location.name,
provider = context.getString(R.string.provider_metno),
providerUrl = "https://www.yr.no/",
icon = iconForCode(symbolCode),
condition = conditionForCode(symbolCode),
precipitation = precipitationAmount,
night = isNight(timestamp, location.lat, location.lon)
)
)
}
return WeatherUpdateResult(
forecasts = forecasts,
location = location
)
} catch (e: JSONException) {
CrashReporter.logException(e)
} catch (e: IOException) {
CrashReporter.logException(e)
}
return null
}
companion object {
private const val PREFERENCES = "metno"
}
}
@@ -0,0 +1,130 @@
package de.mm20.launcher2.weather.openweathermap
import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
import retrofit2.http.Query
data class CurrentWeatherResult(
val coords: WeatherResultCoords?,
val weather: Array<WeatherResultWeather>?,
val main: WeatherResultMain?,
val wind: WeatherResultWind?,
val clouds: WeatherResultClouds?,
val rain: CurrentWeatherResultRain?,
val snow: CurrentWeatherResultSnow?,
val dt: Long?,
val sys: WeatherResultSys?,
val timezone: Long?,
val id: Int?,
val name: String?,
)
data class WeatherResultCoords(
val lon: Double?,
val lat: Double?,
)
data class WeatherResultWeather(
val id: Int?,
val main: String?,
val description: String?,
val icon: String?,
)
data class WeatherResultMain(
val temp: Double?,
@SerializedName("feels_like") val feelsLike: Double?,
val pressure: Double?,
val humidity: Double?,
@SerializedName("temp_min") val tempMin: Double?,
@SerializedName("temp_max") val tempMax: Double?,
@SerializedName("sea_level") val seaLevel: Double?,
@SerializedName("grnd_level") val grndLevel: Double?,
)
data class WeatherResultWind(
val speed: Double?,
val deg: Double?,
val gust: Double?
)
data class WeatherResultClouds(
val all: Int?
)
data class CurrentWeatherResultRain(
@SerializedName("1h") val oneHour: Double,
@SerializedName("3h") val threeHours: Double,
)
data class CurrentWeatherResultSnow(
@SerializedName("1h") val oneHour: Double,
@SerializedName("3h") val threeHours: Double,
)
data class WeatherResultSys(
val country: String?,
val sunrise: Long?,
val sunset: Long?,
)
data class ForecastResult(
val cnt: Int?,
val list: Array<ForecastResultList>?,
val city: ForecastResultCity?,
)
data class ForecastResultList(
val dt: Long?,
val main: WeatherResultMain?,
val weather: Array<WeatherResultWeather>?,
val clouds: WeatherResultClouds?,
val wind: WeatherResultWind?,
val rain: ForecastResultRain?,
val snow: ForecastResultSnow?,
val sys: ForecastResultSys?,
@SerializedName("dt_txt") val dtTxt: String?,
)
data class ForecastResultRain(
@SerializedName("3d") val threeHours: Double?,
)
data class ForecastResultSnow(
@SerializedName("3d") val threeHours: Double?,
)
data class ForecastResultSys(
val pod: String,
)
data class ForecastResultCity(
val id: Int?,
val name: String?,
val coords: WeatherResultCoords?,
val country: String?,
val timezone: Long?,
)
interface OpenWeatherMapApi {
@GET("weather")
suspend fun currentWeather(
@Query("q") q: String? = null,
@Query("id") id: Int? = null,
@Query("lat") lat: Double? = null,
@Query("lon") lon: Double? = null,
@Query("appid") appid: String,
@Query("lang") lang: String,
): CurrentWeatherResult
@GET("forecast")
suspend fun forecast5Day3Hour(
@Query("q") q: String? = null,
@Query("id") id: Int? = null,
@Query("lat") lat: Double? = null,
@Query("lon") lon: Double? = null,
@Query("appid") appid: String,
@Query("lang") lang: String,
): ForecastResult
}
@@ -0,0 +1,265 @@
package de.mm20.launcher2.weather.openweathermap
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.weather.*
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.*
class OpenWeatherMapProvider(override val context: Context) :
WeatherProvider<OpenWeatherMapLocation>() {
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl("https://api.openweathermap.org/data/2.5/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
private val openWeatherMapService by lazy {
retrofit.create(OpenWeatherMapApi::class.java)
}
override fun isUpdateRequired(): Boolean {
return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis()
}
override suspend fun lookupLocation(query: String): List<OpenWeatherMapLocation> {
val lang = Locale.getDefault().language
val response = try {
openWeatherMapService.currentWeather(
appid = getApiKey() ?: return emptyList(),
q = query,
lang = lang
)
} catch (e: Exception) {
CrashReporter.logException(e)
return emptyList()
}
val city = response.name ?: return emptyList()
val country = response.sys?.country ?: ""
val cityId = response.id ?: return emptyList()
val loc = "$city, $country"
return listOf(
OpenWeatherMapLocation(
name = loc, id = cityId
)
)
}
override suspend fun loadWeatherData(location: OpenWeatherMapLocation): WeatherUpdateResult<OpenWeatherMapLocation>? {
return fetchWeatherData(location = location)
}
override suspend fun loadWeatherData(
lat: Double,
lon: Double
): WeatherUpdateResult<OpenWeatherMapLocation>? {
return fetchWeatherData(lat = lat, lon = lon)
}
private suspend fun fetchWeatherData(
lat: Double? = null,
lon: Double? = null,
location: OpenWeatherMapLocation? = null
): WeatherUpdateResult<OpenWeatherMapLocation>? {
val lang = Locale.getDefault().language
val currentWeather = try {
openWeatherMapService.currentWeather(
appid = getApiKey() ?: return null,
id = location?.id?.takeIf { lat == null || lon == null },
lat = lat,
lon = lon,
lang = lang,
)
} catch (e: Exception) {
CrashReporter.logException(e)
return null
}
val forecastList = mutableListOf<Forecast>()
val city = currentWeather.name
val country = currentWeather.sys?.country ?: return null
val cityId = currentWeather.id ?: return null
val loc = "$city, $country"
val forecasts = try {
openWeatherMapService.forecast5Day3Hour(
id = cityId,
appid = getApiKey() ?: return null,
lang = lang
)
} catch (e: Exception) {
CrashReporter.logException(e)
return null
}
forecasts.list ?: return null
forecastList.add(
Forecast(
timestamp = currentWeather.dt?.times(1000) ?: return null,
condition = currentWeather.weather?.getOrNull(0)?.description ?: "Unknown",
temperature = currentWeather.main?.temp ?: return null,
minTemp = currentWeather.main.tempMin ?: -1.0,
maxTemp = currentWeather.main.tempMax ?: -1.0,
pressure = currentWeather.main.pressure ?: -1.0,
humidity = currentWeather.main.humidity ?: -1.0,
precipitation = (currentWeather.rain?.threeHours
?: 0.0) + (currentWeather.snow?.threeHours ?: 0.0),
icon = iconForId(currentWeather.weather?.getOrNull(0)?.id ?: 0),
clouds = currentWeather.clouds?.all ?: 0,
windSpeed = currentWeather.wind?.speed ?: 0.0,
windDirection = currentWeather.wind?.deg ?: -1.0,
night = run {
val sunrise = currentWeather.sys.sunrise ?: 0
val sunset = currentWeather.sys.sunset ?: 0
currentWeather.dt > sunset || currentWeather.dt < sunrise
},
location = loc,
provider = context.getString(R.string.provider_openweathermap),
providerUrl = "https://openweathermap.org/city/$cityId",
updateTime = System.currentTimeMillis()
)
)
forecastList.addAll(
forecasts.list.map {
Forecast(
timestamp = it.dt?.times(1000) ?: return null,
icon = iconForId(it.weather?.getOrNull(0)?.id ?: 0),
condition = it.weather?.getOrNull(0)?.description ?: "Unknown",
temperature = it.main?.temp ?: return null,
minTemp = it.main.tempMin ?: -1.0,
maxTemp = it.main.tempMax ?: -1.0,
pressure = it.main.pressure ?: -1.0,
humidity = it.main.humidity ?: -1.0,
precipitation = (it.rain?.threeHours ?: 0.0) + (currentWeather.snow?.threeHours
?: 0.0),
clouds = it.clouds?.all ?: 0,
windSpeed = it.wind?.speed ?: 0.0,
windDirection = it.wind?.deg ?: -1.0,
night = it.sys?.pod == "n",
location = loc,
provider = context.getString(R.string.provider_openweathermap),
providerUrl = "https://openweathermap.org/city/$cityId",
updateTime = System.currentTimeMillis()
)
}
)
return WeatherUpdateResult(
forecasts = forecastList,
location = OpenWeatherMapLocation(
name = loc,
id = cityId
)
)
}
private fun getApiKey(): String? {
val resId = getApiKeyResId()
if (resId != 0) return context.getString(resId)
return null
}
override fun isAvailable(): Boolean {
return getApiKeyResId() != 0
}
override val name: String
get() = context.getString(R.string.provider_openweathermap)
private fun getApiKeyResId(): Int {
return context.resources.getIdentifier("openweathermap_key", "string", context.packageName)
}
private fun iconForId(id: Int): Int {
return when (id) {
200, 201, in 230..232 -> Forecast.THUNDERSTORM_WITH_RAIN
202 -> Forecast.HEAVY_THUNDERSTORM_WITH_RAIN
210, 211 -> Forecast.THUNDERSTORM
212, 221 -> Forecast.HEAVY_THUNDERSTORM
in 300..302, in 310..312 -> Forecast.DRIZZLE
313, 314, 321, in 500..504, 511, in 520..522, 531 -> Forecast.SHOWERS
in 600..602 -> Forecast.SNOW
611, 612, 615, 616, in 620..622 -> Forecast.SLEET
701, 711, 731, 741, 761, 762 -> Forecast.FOG
721 -> Forecast.HAZE
771, 781, in 900..902, in 958..962 -> Forecast.STORM
800 -> Forecast.CLEAR
801 -> Forecast.PARTLY_CLOUDY
802 -> Forecast.MOSTLY_CLOUDY
803 -> Forecast.BROKEN_CLOUDS
804, 951 -> Forecast.CLOUDY
903 -> Forecast.COLD
904 -> Forecast.HOT
905, in 952..957 -> Forecast.WIND
906 -> Forecast.HAIL
else -> Forecast.NONE
}
}
override val preferences: SharedPreferences
get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
override fun setLocation(location: WeatherLocation?) {
location as OpenWeatherMapLocation?
preferences.edit {
if (location == null) {
remove(CITY_ID)
remove(LOCATION)
} else {
putInt(CITY_ID, location.id)
putString(LOCATION, location.name)
}
}
}
override fun getLocation(): OpenWeatherMapLocation? {
val id = preferences.getInt(CITY_ID, -1).takeIf { it != -1 } ?: return null
val name = preferences.getString(LOCATION, null) ?: return null
return OpenWeatherMapLocation(
name = name,
id = id,
)
}
override fun getLastLocation(): OpenWeatherMapLocation? {
val id = preferences.getInt(LAST_CITY_ID, -1).takeIf { it != -1 } ?: return null
val name = preferences.getString(LAST_LOCATION, null) ?: return null
return OpenWeatherMapLocation(
name = name,
id = id,
)
}
override fun saveLastLocation(location: OpenWeatherMapLocation) {
preferences.edit {
putString(LAST_LOCATION, location.name)
putInt(LAST_CITY_ID, location.id)
}
}
companion object {
private const val PREFERENCES = "openweathermap"
private const val CITY_ID = "city_id"
private const val LAST_CITY_ID = "last_city_id"
private const val LAST_UPDATE = "last_update"
private const val LOCATION = "location"
private const val LAST_LOCATION = "last_location"
}
}
data class OpenWeatherMapLocation(
override val name: String,
val id: Int
) : WeatherLocation
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--<string name="openweathermap_key" translatable="false">xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</string>-->
<!--<string name="here_key" translatable="false">xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</string>-->
<!--<string name="metno_contact" translatable="false"></string>-->
</resources>