Refactor weather providers

This commit is contained in:
MM20 2021-09-20 22:23:03 +02:00
parent e77901eb6a
commit 65d4047cf3
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
9 changed files with 574 additions and 499 deletions

View File

@ -13,6 +13,7 @@ import com.afollestad.materialdialogs.list.listItemsSingleChoice
import de.mm20.launcher2.R
import de.mm20.launcher2.preferences.LauncherPreferences
import de.mm20.launcher2.preferences.WeatherProviders
import de.mm20.launcher2.weather.WeatherLocation
import de.mm20.launcher2.weather.WeatherProvider
import de.mm20.launcher2.weather.WeatherViewModel
import de.mm20.launcher2.weather.here.HereProvider
@ -89,7 +90,7 @@ class PreferencesWeatherFragment : PreferenceFragmentCompat() {
val provider = WeatherProvider.getInstance(requireContext())
provider?.autoLocation = autoLocation
provider?.resetLastUpdate()
provider?.setLocation(null, "")
provider?.setLocation(null)
ViewModelProvider(this).get(WeatherViewModel::class.java)
.requestUpdate(requireContext())
updateProviderPreferences()
@ -105,47 +106,28 @@ class PreferencesWeatherFragment : PreferenceFragmentCompat() {
val unitsPref = findPreference<Preference>("imperial_units")!!
val providerPref = findPreference<Preference>("weather_provider")!!
locationPref.parent?.isVisible = provider != null
unitsPref.isVisible = provider != null
provider ?: return
providerPref.summary = provider.name
if (provider.supportsAutoLocation) {
autoLocationPref.setSummary(R.string.preference_automatic_location_summary)
autoLocationPref.isChecked = provider.autoLocation
if (!provider.supportsManualLocation) {
autoLocationPref.isEnabled = false
autoLocationPref.isChecked = true
locationPref.isEnabled = false
locationPref.setSummary(R.string.preference_location_disabled_summary)
} else {
autoLocationPref.isEnabled = true
autoLocationPref.isChecked = provider.autoLocation
locationPref.isEnabled = true
locationPref.summary = provider.getLastLocation()
}
} else {
autoLocationPref.isEnabled = false
autoLocationPref.setSummary(R.string.preference_automatic_location_disabled_summary)
autoLocationPref.isChecked = false
}
autoLocationPref.isChecked = provider.autoLocation
locationPref.summary =
if (provider.autoLocation) provider.getLastLocation()?.name else provider.getLocation()?.name
}
private fun onLookupCompleted(results: List<Pair<Any?, String>>) {
private fun onLookupCompleted(results: List<WeatherLocation>) {
MaterialDialog(requireContext())
.listItems(
items = results.map { it.second },
items = results.map { it.name },
waitForPositiveButton = false
) { dialog, index, _ ->
val provider = WeatherProvider.getInstance(requireContext())
?: return@listItems dialog.dismiss()
provider.resetLastUpdate()
provider.setLocation(results[index].first, results[index].second)
findPreference<Preference>("location")?.summary = results[index].second
provider.setLocation(results[index])
findPreference<Preference>("location")?.summary = results[index].name
ViewModelProvider(this).get(WeatherViewModel::class.java)
.requestUpdate(requireContext())
dialog.dismiss()

View File

@ -0,0 +1,115 @@
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
/**
* 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> {
if (!Geocoder.isPresent()) return emptyList()
val geocoder = Geocoder(context)
val locations =
withContext(Dispatchers.IO) {
geocoder.getFromLocationName(query, 10)
}
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 = Geocoder(context).getFromLocation(lat, lon, 1)
.firstOrNull()
?.formatToString() ?: "$lat/$lon"
loadWeatherData(
LatLonWeatherLocation(
name = locationName,
lat = lat,
lon = lon
)
)
} catch (e: IOException) {
CrashReporter.logException(e)
null
}
}
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

View File

@ -0,0 +1,5 @@
package de.mm20.launcher2.weather
interface WeatherLocation {
val name: String
}

View File

@ -1,51 +1,118 @@
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
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 class WeatherProvider<T : WeatherLocation> {
abstract val supportsAutoLocation: Boolean
internal abstract val context: Context
abstract val supportsManualLocation: Boolean
internal abstract val preferences: SharedPreferences
abstract var autoLocation: Boolean
var autoLocation: Boolean
get() {
return preferences.getBoolean(AUTO_LOCATION, true)
}
set(value) {
preferences.edit {
putBoolean(AUTO_LOCATION, value)
}
}
abstract suspend fun fetchNewWeatherData(): List<Forecast>?
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
abstract fun getLastUpdate(): Long
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 Pair<Any?,String> with provider specific data of that location and its
* display name
* @return a list of locations
*/
abstract suspend fun lookupLocation(query: String): List<Pair<Any?, String>>
abstract suspend fun lookupLocation(query: String): List<T>
abstract fun setLocation(locationId: Any?, locationName: String)
/**
* @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 {
fun getInstance(context: Context): WeatherProvider? {
fun getInstance(context: Context): WeatherProvider<out WeatherLocation>? {
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()
}
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
)

View File

@ -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
}

View File

@ -1,117 +1,68 @@
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 android.content.SharedPreferences
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 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(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)
}
}
class HereProvider(override val context: Context) : LatLonWeatherProvider() {
override suspend fun fetchNewWeatherData(): List<Forecast>? {
val prefs = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
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()
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 apiKey = getApiKey() ?: return null
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 response = hereWeatherService.report(
apiKey = apiKey,
language = lang,
latitude = lat,
longitude = lon
)
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 = response.hourlyForecasts?.forecastLocation ?: return null
val forecasts = forecastLocation.forecast ?: return null
val forecastLocation = JSONObject(body)
.getJSONObject("hourlyForecasts")
.getJSONObject("forecastLocation")
val forecasts = forecastLocation.getJSONArray("forecast")
val location = forecastLocation.city ?: return null
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)
for (forecast in forecasts) {
val timestamp = try {
dateFormat.parse(forecast.getString("utcTime"))?.time ?: continue
dateFormat.parse(forecast.utcTime ?: continue)?.time ?: continue
} catch (e: ParseException) {
CrashReporter.logException(e)
return null
@ -121,24 +72,20 @@ class HereProvider(val context: Context) : WeatherProvider() {
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")
!forecast.precipitationDesc.isNullOrEmpty() -> forecast.precipitationDesc
!forecast.skyDescription.isNullOrEmpty() -> forecast.skyDescription
!forecast.temperatureDesc.isNullOrEmpty() -> forecast.temperatureDesc
else -> forecast.description ?: continue
}
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)
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.getString("windDirection").toIntOrNull() ?: 0
val windSpeed = forecast.getString("windSpeed").toDoubleOrNull() ?: 0.0
val windDir = forecast.windDirection?.toIntOrNull() ?: 0
val windSpeed = forecast.windSpeed?.toDoubleOrNull() ?: 0.0
forecastList.add(
Forecast(
@ -162,19 +109,23 @@ class HereProvider(val context: Context) : WeatherProvider() {
)
}
return WeatherUpdateResult(
forecasts = forecastList,
location = LatLonWeatherLocation(
name = location,
lat = lat,
lon = lon
)
)
} catch (e: JSONException) {
} catch (e: Exception) {
CrashReporter.logException(e)
return null
}
prefs.edit {
putLong(LAST_UPDATE, updateTime)
}
return forecastList
}
private fun getIcon(iconName: String): Int {
with(Forecast) {
return when (iconName) {
@ -334,62 +285,29 @@ class HereProvider(val context: Context) : WeatherProvider() {
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)
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 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) {
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()
}
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)
@ -410,11 +328,5 @@ class HereProvider(val context: Context) : WeatherProvider() {
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"
}
}
}

View File

@ -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
}

View File

@ -2,26 +2,23 @@ package de.mm20.launcher2.weather.metno
import android.Manifest
import android.content.Context
import android.content.SharedPreferences
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 de.mm20.launcher2.weather.*
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
@ -31,149 +28,10 @@ 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)
}
}
class MetNoProvider(override val context: Context) : LatLonWeatherProvider() {
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
}
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()
@ -201,10 +59,6 @@ class MetNoProvider(val context: Context) : WeatherProvider() {
}
private fun isSnow(code: String): Boolean {
return code.contains("snow")
}
private fun conditionForCode(code: String): String {
return context.getString(
when (code.substringBefore("_")) {
@ -284,48 +138,6 @@ class MetNoProvider(val context: Context) : WeatherProvider() {
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
@ -367,15 +179,87 @@ class MetNoProvider(val context: Context) : WeatherProvider() {
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"
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"
}
}

View File

@ -1,48 +1,34 @@
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 android.content.SharedPreferences
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 de.mm20.launcher2.weather.*
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() {
class OpenWeatherMapProvider(override val context: Context) :
WeatherProvider<OpenWeatherMapLocation>() {
val retrofit by lazy {
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl("https://api.openweathermap.org/data/2.5/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val openWeatherMapService by lazy {
private 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>> {
override suspend fun lookupLocation(query: String): List<OpenWeatherMapLocation> {
val lang = Locale.getDefault().language
val response = try {
@ -60,68 +46,37 @@ class OpenWeatherMapProvider(val context: Context) : WeatherProvider() {
val country = response.sys?.country ?: ""
val cityId = response.id ?: return emptyList()
val loc = "$city, $country"
return listOf(cityId.toString() to loc)
return listOf(
OpenWeatherMapLocation(
name = loc, id = cityId
)
)
}
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 suspend fun loadWeatherData(location: OpenWeatherMapLocation): WeatherUpdateResult<OpenWeatherMapLocation>? {
return fetchWeatherData(location = location)
}
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 suspend fun loadWeatherData(
lat: Double,
lon: Double
): WeatherUpdateResult<OpenWeatherMapLocation>? {
return fetchWeatherData(lat = lat, lon = lon)
}
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)
}
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 = cityId.takeIf { it != -1 && location == null },
lat = location?.latitude,
lon = location?.longitude,
id = location?.id?.takeIf { lat == null || lon == null },
lat = lat,
lon = lon,
lang = lang,
)
} catch (e: Exception) {
@ -133,7 +88,7 @@ class OpenWeatherMapProvider(val context: Context) : WeatherProvider() {
val city = currentWeather.name
val country = currentWeather.sys?.country ?: return null
cityId = currentWeather.id ?: return null
val cityId = currentWeather.id ?: return null
val loc = "$city, $country"
val forecasts = try {
@ -200,20 +155,15 @@ class OpenWeatherMapProvider(val context: Context) : WeatherProvider() {
)
}
)
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
return WeatherUpdateResult(
forecasts = forecastList,
location = OpenWeatherMapLocation(
name = loc,
id = cityId
)
)
}
private fun getApiKey(): String? {
val resId = getApiKeyResId()
if (resId != 0) return context.getString(resId)
@ -258,12 +208,58 @@ class OpenWeatherMapProvider(val context: Context) : WeatherProvider() {
}
}
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"
private const val AUTO_LOCATION = "auto_location"
}
}
}
data class OpenWeatherMapLocation(
override val name: String,
val id: Int
) : WeatherLocation