Initial commit

This commit is contained in:
MM20
2021-09-18 23:37:52 +02:00
commit 749e4e3073
938 changed files with 50475 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/build
src/main/res/values/config.xml
+55
View File
@@ -0,0 +1,55 @@
plugins {
id("com.android.library")
id("kotlin-android")
id("kotlin-android-extensions")
}
android {
compileSdk = sdk.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = sdk.versions.minSdk.get().toInt()
targetSdk = sdk.versions.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.preference)
implementation(libs.androidx.work)
implementation(libs.okhttp)
implementation(libs.bundles.retrofit)
implementation(libs.suncalc)
implementation(project(":database"))
implementation(project(":ktx"))
implementation(project(":crashreporter"))
implementation(project(":preferences"))
implementation(project(":i18n"))
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.kts.kts.kts.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
+50
View File
@@ -0,0 +1,50 @@
# :weather
⚠️ Depends on non-free external services.
This module manages weather data.
## Configuration
This module requires additional configuration in order to work properly. You can skip this step but
then weather related features will not be available. You need only configure the providers you plan
to use.
1. Copy the `./src/main/res/values/config_example.xml` to `./src/main/res/values/config.xml`
### OpenWeatherMap
OpenWeatherMap offers 1 000 000 free API calls per month. However forecasts are only available for
the next 5 days and only every 3 hours. Also note that each weather update uses two API calls (
current weather + forecast).
1. Register at [OpenWeatherMap](https://openweathermap.org/)
2. Navigate to your user profile > API keys
3. Create a new API key
4. Uncomment the resource `openweather_key` in config.xml and paste your key as value.
### HERE
HERE offers 250 000 free API calls per month. Each weather update uses one call.
1. Sign up at the [HERE developer portal](https://developer.here.com/)
2. Create a new project.
3. Go to project details and under JavaScript, create a new API key.
4. Uncomment the resource `here_key` in config.xml and paste your key as value.
### Meteorologisk institutt
The Norwegian Meteorological Institute offers a free weather API. It has a rate limit of 20
requests/s. You do not need to register or to provide an API key, however they require an
identification and contact data to be present in the User Agent header in each request.
It should also be noted that MET Norway only supports weather requests by lat/lon and the responses do
not include geocoded names. This means a geocoder has to be present in the operating system for this
provider to fully work (most devices ship a geocoder as part of the Google Play Services). If no
geocoder is present, manual location mode will not be supported and the weather widget will show the
lat/lon values instead of a location name.
1. Uncomment `metno_contact` in config.xml. Fill in your contact data (an email address or a website
where your contact data can be found). This will be used in the User Agent header, which will be
composed like
this: `"{app package name}[signature:{app siganture hash}] {@string/metno_contact}"`
+5
View File
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="de.mm20.launcher2.weather">
<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,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,51 @@
package de.mm20.launcher2.weather
import android.content.Context
import de.mm20.launcher2.preferences.LauncherPreferences
import de.mm20.launcher2.preferences.WeatherProviders
import de.mm20.launcher2.weather.here.HereProvider
import de.mm20.launcher2.weather.metno.MetNoProvider
import de.mm20.launcher2.weather.openweathermap.OpenWeatherMapProvider
abstract class WeatherProvider {
abstract val supportsAutoLocation: Boolean
abstract val supportsManualLocation: Boolean
abstract var autoLocation: Boolean
abstract suspend fun fetchNewWeatherData(): List<Forecast>?
abstract fun isUpdateRequired(): Boolean
abstract fun getLastUpdate(): Long
/**
* Lookup a location based on a string query.
* @param query the location to lookup
* @return a list of Pair<Any?,String> with provider specific data of that location and its
* display name
*/
abstract suspend fun lookupLocation(query: String): List<Pair<Any?, String>>
abstract fun setLocation(locationId: Any?, locationName: String)
abstract fun isAvailable(): Boolean
abstract val name: String
companion object {
fun getInstance(context: Context): WeatherProvider? {
return when (LauncherPreferences.instance.weatherProvider) {
WeatherProviders.OPENWEATHERMAP -> OpenWeatherMapProvider(context)
WeatherProviders.HERE -> HereProvider(context)
else -> MetNoProvider(context)
}.takeIf { it.isAvailable() }
}
}
abstract fun getLastLocation(): String
abstract fun resetLastUpdate()
}
@@ -0,0 +1,90 @@
package de.mm20.launcher2.weather
import android.content.Context
import android.util.Log
import androidx.lifecycle.MediatorLiveData
import androidx.work.*
import de.mm20.launcher2.database.AppDatabase
import java.util.*
import java.util.concurrent.TimeUnit
class WeatherRepository(
val context: Context
) {
val forecasts = MediatorLiveData<List<DailyForecast>>()
init {
forecasts.addSource(AppDatabase.getInstance(context).weatherDao().getForecasts()) { entities ->
forecasts.value = sortDailyForecasts(entities.map { Forecast(it) })
}
val weatherRequest = PeriodicWorkRequest.Builder(WeatherUpdateWorker::class.java, 60, TimeUnit.MINUTES)
.build()
WorkManager.getInstance().enqueueUniquePeriodicWork("weather",
ExistingPeriodicWorkPolicy.KEEP, weatherRequest)
}
private fun sortDailyForecasts(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
}
fun requestUpdate(context: Context) {
val provider = WeatherProvider.getInstance(context) ?: return
if (provider.isUpdateRequired()) {
val weatherRequest = OneTimeWorkRequest.Builder(WeatherUpdateWorker::class.java)
.addTag("weather")
.build()
WorkManager.getInstance(context).enqueue(weatherRequest)
} else {
Log.d("MM20", "No weather update required")
}
}
}
class WeatherUpdateWorker(val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val provider = WeatherProvider.getInstance(context) ?: return Result.failure()
if (!provider.isAvailable()) return Result.failure()
if (!provider.isUpdateRequired()) return Result.failure()
val weatherData = provider.fetchNewWeatherData()
return if (weatherData == null) {
Log.d("MM20", "Weather update failed")
Result.retry()
} else {
Log.d("MM20", "Weather update succeeded")
AppDatabase.getInstance(applicationContext).weatherDao().replaceAll(weatherData.map { it.toDatabaseEntity() })
Result.success()
}
}
}
@@ -0,0 +1,23 @@
package de.mm20.launcher2.weather
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
class WeatherViewModel(application: Application) : AndroidViewModel(application) {
private val repository = WeatherRepository(application)
init {
requestUpdate(application)
}
val forecasts: LiveData<List<DailyForecast>> by lazy {
repository.forecasts
}
fun requestUpdate(context: Context) {
repository.requestUpdate(context)
}
}
@@ -0,0 +1,420 @@
package de.mm20.launcher2.weather.here
import android.Manifest
import android.content.Context
import android.location.LocationManager
import android.util.Log
import androidx.core.content.edit
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.ktx.getDouble
import de.mm20.launcher2.ktx.putDouble
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.R
import de.mm20.launcher2.weather.WeatherProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.net.URLEncoder
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
class HereProvider(val context: Context) : WeatherProvider() {
override val supportsAutoLocation = true
override val supportsManualLocation = true
override var autoLocation: Boolean
get() {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getBoolean(AUTO_LOCATION, true)
}
set(value) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit {
putBoolean(AUTO_LOCATION, value)
}
}
override suspend fun fetchNewWeatherData(): List<Forecast>? {
val prefs = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val updateTime = System.currentTimeMillis()
var query: String? = null
if (autoLocation) {
var lat: Double? = null
var lon: Double? = null
if (context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
lat = location?.latitude
lon = location?.longitude
if (lat != null && lon != null) {
prefs.edit {
putDouble(LAST_LAT, lat!!)
putDouble(LAST_LON, lon!!)
}
}
}
if (lat == null || lon == null) {
lat = prefs.getDouble(LAST_LAT)
lon = prefs.getDouble(LAST_LON)
}
if (lat != null && lon != null) query = "latitude=$lat&longitude=$lon"
}
if (!autoLocation || query == null) {
val name = prefs.getString(CITY_NAME, null) ?: return null
query = "name=$name"
}
val lang = Locale.getDefault().language
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ROOT)
val forecastList = mutableListOf<Forecast>()
try {
val httpClient = OkHttpClient()
val forecastRequest = Request.Builder()
.url("https://weather.ls.hereapi.com/weather/1.0/report.json?apiKey=${getApiKey()}&product=forecast_hourly&$query&language=$lang")
.get()
.build()
val body = withContext(Dispatchers.IO) {
httpClient.newCall(forecastRequest).execute().body?.string()
} ?: run {
Log.e("MM20", "Here provider: forecast request returned null")
return null
}
val forecastLocation = JSONObject(body)
.getJSONObject("hourlyForecasts")
.getJSONObject("forecastLocation")
val forecasts = forecastLocation.getJSONArray("forecast")
val location = forecastLocation.getString("city")
val locationLong =
"${forecastLocation.getString("city")}, ${forecastLocation.getString("country")}"
if (autoLocation) {
prefs.edit {
putString(LAST_LOCATION, locationLong)
}
}
for (i in 0 until forecasts.length()) {
val forecast = forecasts.getJSONObject(i)
val timestamp = try {
dateFormat.parse(forecast.getString("utcTime"))?.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.optString("precipitationDesc")
.isNullOrEmpty() -> forecast.optString("precipitationDesc")
!forecast.optString("skyDescription")
.isNullOrEmpty() -> forecast.optString("skyDescription")
!forecast.optString("temperatureDesc")
.isNullOrEmpty() -> forecast.optString("temperatureDesc")
else -> forecast.optString("description")
}
val humidity = forecast.getString("humidity").toIntOrNull() ?: 0
val icon = getIcon(forecast.getString("iconName"))
val night = forecast.getString("daylight") == "N"
val rain = forecast.getString("rainFall").toDoubleOrNull() ?: 0.0
val snow = forecast.getString("snowFall").toDoubleOrNull() ?: 0.0
val rainPercent = forecast.getString("precipitationProbability").toIntOrNull() ?: 0
val temperature = forecast.getString("temperature").toDoubleOrNull()?.plus(273.15)
?: 0.0
val windDir = forecast.getString("windDirection").toIntOrNull() ?: 0
val windSpeed = forecast.getString("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
)
)
}
} catch (e: JSONException) {
CrashReporter.logException(e)
return null
}
prefs.edit {
putLong(LAST_UPDATE, updateTime)
}
return forecastList
}
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 fun getLastUpdate(): Long {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getLong(LAST_UPDATE, 0)
}
override suspend fun lookupLocation(query: String): List<Pair<Any?, String>> {
val urlString =
"https://geocoder.ls.hereapi.com/6.2/geocode.json?apiKey=${getApiKey()}&searchtext=$query"
val client = OkHttpClient()
val request = Request.Builder()
.url(urlString)
.build()
try {
val body = withContext(Dispatchers.IO) {
val response = client.newCall(request).execute()
response.body?.string()
} ?: return emptyList()
val json = JSONObject(body)
val results = json
.optJSONObject("Response")
?.optJSONArray("View")
?.optJSONObject(0)
?.optJSONArray("Result") ?: return emptyList()
val locations = mutableListOf<Pair<Any?, String>>()
for (i in 0 until results.length()) {
val result = results.getJSONObject(i)
val location = result.optJSONObject("Location") ?: continue
val name = location.optJSONObject("Address")?.getString("Label") ?: continue
locations.add(URLEncoder.encode(name, "UTF-8") to name)
}
return locations
} catch (e: JSONException) {
} catch (e: IOException) {
}
return emptyList()
}
override fun setLocation(locationId: Any?, locationName: String) {
val id = locationId as? String ?: return
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit {
putString(CITY_NAME, id)
putString(LAST_LOCATION, locationName)
}
}
override fun getLastLocation(): String {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getString(LAST_LOCATION, "")!!
}
override fun resetLastUpdate() {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit {
putLong(LAST_UPDATE, 0)
}
}
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"
private const val LAST_LAT = "last_lat"
private const val LAST_LON = "last_lon"
private const val LAST_UPDATE = "last_update"
private const val CITY_NAME = "city_name"
private const val LAST_LOCATION = "last_location"
private const val AUTO_LOCATION = "auto_location"
}
}
@@ -0,0 +1,381 @@
package de.mm20.launcher2.weather.metno
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.LocationManager
import android.os.Build
import android.util.Base64
import android.util.Log
import androidx.core.content.edit
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.ktx.formatToString
import de.mm20.launcher2.ktx.getDouble
import de.mm20.launcher2.ktx.putDouble
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.R
import de.mm20.launcher2.weather.WeatherProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.userAgent
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(val context: Context) : WeatherProvider() {
override val supportsAutoLocation: Boolean
get() = true
override val supportsManualLocation: Boolean
get() = Geocoder.isPresent()
override var autoLocation: Boolean
get() {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getBoolean(AUTO_LOCATION, true)
}
set(value) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit {
putBoolean(AUTO_LOCATION, value)
}
}
override suspend fun fetchNewWeatherData(): List<Forecast>? {
var lat: Double? = null
var lon: Double? = null
var locationName: String? = null
val updateTime = System.currentTimeMillis()
val prefs = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val lastUpdate = prefs.getLong(LAST_UPDATE, 0L)
val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.ROOT)
val ifModifiedSince = httpDateFormat.format(Date(lastUpdate))
if (autoLocation &&
context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION)
) {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
lat = location?.latitude
lon = location?.longitude
if (Geocoder.isPresent() && lat != null && lon != null) {
try {
locationName = Geocoder(context).getFromLocation(lat, lon, 1)
.firstOrNull()
?.formatToString() ?: "$lat/$lon"
prefs.edit {
putString(LAST_LOCATION_NAME, locationName)
lat?.let { putDouble(LAST_LAT, it) }
lon?.let { putDouble(LAST_LON, it) }
}
} catch (e: IOException) {
CrashReporter.logException(e)
return null
}
}
}
if (!autoLocation) {
if (!prefs.contains(LON) || !prefs.contains(LAT)) return null
lat = prefs.getDouble(LAT)
lon = prefs.getDouble(LON)
locationName = prefs.getString(LAST_LOCATION_NAME, null) ?: "$lat/$lon"
}
if (lat == null || lon == null) {
if (!prefs.contains(LAST_LON) || !prefs.contains(LAST_LAT)) return null
lat = prefs.getDouble(LAST_LAT)
lon = prefs.getDouble(LAST_LON)
locationName = prefs.getString(LAST_LOCATION_NAME, null) ?: "$lat/$lon"
}
if (lat == null || lon == null || locationName == null) return null
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", lat)
val lonParam = String.format(Locale.ROOT, "%.4f", 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 = locationName,
provider = context.getString(R.string.provider_metno),
providerUrl = "https://www.yr.no/",
icon = iconForCode(symbolCode),
condition = conditionForCode(symbolCode),
precipitation = precipitationAmount,
night = isNight(timestamp, lat, lon)
)
)
}
prefs.edit {
putLong(LAST_UPDATE, updateTime)
}
return forecasts
} catch (e: JSONException) {
CrashReporter.logException(e)
} catch (e: IOException) {
CrashReporter.logException(e)
}
return null
}
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.time
}
if (set != null && rise == null) {
return set.time < timestamp
}
if (set == null || rise == null) return false
if (set.time < rise.time) {
return (set.time < timestamp && timestamp < rise.time)
}
return !(rise.time < timestamp && timestamp < set.time)
}
private fun isSnow(code: String): Boolean {
return code.contains("snow")
}
private fun conditionForCode(code: String): String {
return context.getString(
when (code.substringBefore("_")) {
"sleetshowers" -> R.string.weather_sleetshowers
"heavysleet" -> R.string.weather_heavysleet
"lightrainshowersandthunder" -> R.string.weather_lightrainshowersandthunder
"heavyrain" -> R.string.weather_heavyrain
"lightsnowandthunder" -> R.string.weather_lightsnowandthunder
"lightrain" -> R.string.weather_lightrain
"lightrainshowers" -> R.string.weather_lightrainshowers
"lightsnow" -> R.string.weather_lightsnow
"heavysleetshowersandthunder" -> R.string.weather_heavysleetshowersandthunder
"lightsnowshowers" -> R.string.weather_lightsnowshowers
"lightssleetshowersandthunder" -> R.string.weather_lightssleetshowersandthunder
"snowandthunder" -> R.string.weather_snowandthunder
"heavysleetshowers" -> R.string.weather_heavysleetshowers
"heavysnow" -> R.string.weather_heavysnow
"cloudy" -> R.string.weather_cloudy
"lightrainandthunder" -> R.string.weather_lightrainandthunder
"snow" -> R.string.weather_snow
"heavysnowshowers" -> R.string.weather_heavysnowshowers
"heavyrainshowers" -> R.string.weather_heavyrainshowers
"rainshowersandthunder" -> R.string.weather_rainshowersandthunder
"clearsky" -> R.string.weather_clearsky
"sleet" -> R.string.weather_sleet
"rain" -> R.string.weather_rain
"sleetandthunder" -> R.string.weather_sleetandthunder
"lightssnowshowersandthunder" -> R.string.weather_lightssnowshowersandthunder
"heavyrainshowersandthunder" -> R.string.weather_heavyrainshowersandthunder
"fair" -> R.string.weather_fair
"fog" -> R.string.weather_fog
"sleetshowersandthunder" -> R.string.weather_sleetshowersandthunder
"rainandthunder" -> R.string.weather_rainandthunder
"lightsleet" -> R.string.weather_lightsleet
"heavysleetandthunder" -> R.string.weather_heavysleetandthunder
"partlycloudy" -> R.string.weather_partlycloudy
"heavysnowandthunder" -> R.string.weather_heavysnowandthunder
"rainshowers" -> R.string.weather_rainshowers
"lightsleetandthunder" -> R.string.weather_lightsleetandthunder
"heavysnowshowersandthunder" -> R.string.weather_heavysnowshowersandthunder
"lightsleetshowers" -> R.string.weather_lightsleetshowers
"snowshowersandthunder" -> R.string.weather_snowshowersandthunder
"snowshowers" -> R.string.weather_snowshowers
"heavyrainandthunder" -> R.string.weather_heavyrainandthunder
else -> R.string.weather_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()
}
override fun getLastUpdate(): Long {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getLong(LAST_UPDATE, 0)
}
override suspend fun lookupLocation(query: String): List<Pair<Any?, String>> {
if (!Geocoder.isPresent()) return emptyList()
val geocoder = Geocoder(context)
val locations =
withContext(Dispatchers.IO) {
geocoder.getFromLocationName(query, 10)
}
return locations.mapNotNull {
(it.latitude to it.longitude) to it.formatToString()
}
}
/**
* locationId must be a Pair<Double, Double> with the latitude as first and longitude as second
* parameter
*/
override fun setLocation(locationId: Any?, locationName: String) {
if (locationId !is Pair<*, *>) return
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit {
putDouble(LAT, locationId.first as Double)
putDouble(LON, locationId.second as Double)
putString(LAST_LOCATION_NAME, locationName)
}
}
override fun getLastLocation(): String {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getString(LAST_LOCATION_NAME, "")!!
}
override fun resetLastUpdate() {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit {
putLong(LAST_UPDATE, 0L)
}
}
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)
}
companion object {
private const val PREFERENCES = "metno"
private const val AUTO_LOCATION = "auto_location"
private const val LAST_UPDATE = "last_update"
private const val EXPIRES = "expires"
private const val LAT = "lat"
private const val LON = "lon"
private const val LAST_LAT = "last_lat"
private const val LAST_LON = "last_lon"
private const val LAST_LOCATION_NAME = "last_location_name"
}
}
@@ -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,269 @@
package de.mm20.launcher2.weather.openweathermap
import android.Manifest
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.util.Log
import androidx.core.content.edit
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.ktx.checkPermission
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.weather.R
import de.mm20.launcher2.weather.WeatherProvider
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.IOException
import java.lang.Exception
import java.util.*
class OpenWeatherMapProvider(val context: Context) : WeatherProvider() {
val retrofit by lazy {
Retrofit.Builder()
.baseUrl("https://api.openweathermap.org/data/2.5/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val openWeatherMapService by lazy {
retrofit.create(OpenWeatherMapApi::class.java)
}
override fun resetLastUpdate() {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit {
putLong(LAST_UPDATE, 0)
}
}
override fun isUpdateRequired(): Boolean {
return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis()
}
override suspend fun lookupLocation(query: String): List<Pair<Any?, String>> {
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(cityId.toString() to loc)
}
override fun setLocation(locationId: Any?, locationName: String) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit {
putInt(CITY_ID, locationId as? Int ?: -1)
putString(LAST_LOCATION, locationName)
}
}
override var autoLocation: Boolean
get() {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getBoolean(AUTO_LOCATION, true)
}
set(value) {
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit {
putBoolean(AUTO_LOCATION, value)
}
}
override val supportsAutoLocation: Boolean = true
override val supportsManualLocation: Boolean = true
override fun getLastUpdate(): Long {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getLong(LAST_UPDATE, 0)
}
override fun getLastLocation(): String {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getString(LAST_LOCATION, "")!!
}
override suspend fun fetchNewWeatherData(): List<Forecast>? {
Log.d("MM20", "Updating weather data… (OpenWeatherMap)")
var cityId = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getInt(CITY_ID, -1)
val lastCityId = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getInt(LAST_CITY_ID, -1)
if (cityId == -1) cityId = lastCityId
val lm = context.getSystemService(
Context.LOCATION_SERVICE
) as LocationManager
var location: Location? = null
if (cityId == -1 && !context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) {
Log.w("MM20", "Location permission is missing")
return null
}
if (context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION) && autoLocation) {
location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
}
val lang = Locale.getDefault().language
val currentWeather = try {
openWeatherMapService.currentWeather(
appid = getApiKey() ?: return null,
id = cityId.takeIf { it != -1 && location == null },
lat = location?.latitude,
lon = location?.longitude,
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
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()
)
}
)
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit {
putInt(CITY_ID, cityId)
putInt(LAST_CITY_ID, cityId)
putLong(LAST_UPDATE, System.currentTimeMillis())
putString(LAST_LOCATION, loc)
}
return forecastList
}
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
}
}
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 LAST_LOCATION = "last_location"
private const val AUTO_LOCATION = "auto_location"
}
}
@@ -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>