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