Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
src/main/res/values/config.xml
|
||||
@@ -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"))
|
||||
|
||||
}
|
||||
Vendored
+21
@@ -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
|
||||
@@ -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}"`
|
||||
@@ -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
|
||||
}
|
||||
+269
@@ -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>
|
||||
Reference in New Issue
Block a user