Refactor weather providers
This commit is contained in:
parent
e77901eb6a
commit
65d4047cf3
@ -13,6 +13,7 @@ import com.afollestad.materialdialogs.list.listItemsSingleChoice
|
|||||||
import de.mm20.launcher2.R
|
import de.mm20.launcher2.R
|
||||||
import de.mm20.launcher2.preferences.LauncherPreferences
|
import de.mm20.launcher2.preferences.LauncherPreferences
|
||||||
import de.mm20.launcher2.preferences.WeatherProviders
|
import de.mm20.launcher2.preferences.WeatherProviders
|
||||||
|
import de.mm20.launcher2.weather.WeatherLocation
|
||||||
import de.mm20.launcher2.weather.WeatherProvider
|
import de.mm20.launcher2.weather.WeatherProvider
|
||||||
import de.mm20.launcher2.weather.WeatherViewModel
|
import de.mm20.launcher2.weather.WeatherViewModel
|
||||||
import de.mm20.launcher2.weather.here.HereProvider
|
import de.mm20.launcher2.weather.here.HereProvider
|
||||||
@ -89,7 +90,7 @@ class PreferencesWeatherFragment : PreferenceFragmentCompat() {
|
|||||||
val provider = WeatherProvider.getInstance(requireContext())
|
val provider = WeatherProvider.getInstance(requireContext())
|
||||||
provider?.autoLocation = autoLocation
|
provider?.autoLocation = autoLocation
|
||||||
provider?.resetLastUpdate()
|
provider?.resetLastUpdate()
|
||||||
provider?.setLocation(null, "")
|
provider?.setLocation(null)
|
||||||
ViewModelProvider(this).get(WeatherViewModel::class.java)
|
ViewModelProvider(this).get(WeatherViewModel::class.java)
|
||||||
.requestUpdate(requireContext())
|
.requestUpdate(requireContext())
|
||||||
updateProviderPreferences()
|
updateProviderPreferences()
|
||||||
@ -105,47 +106,28 @@ class PreferencesWeatherFragment : PreferenceFragmentCompat() {
|
|||||||
val unitsPref = findPreference<Preference>("imperial_units")!!
|
val unitsPref = findPreference<Preference>("imperial_units")!!
|
||||||
val providerPref = findPreference<Preference>("weather_provider")!!
|
val providerPref = findPreference<Preference>("weather_provider")!!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
locationPref.parent?.isVisible = provider != null
|
locationPref.parent?.isVisible = provider != null
|
||||||
unitsPref.isVisible = provider != null
|
unitsPref.isVisible = provider != null
|
||||||
|
|
||||||
provider ?: return
|
provider ?: return
|
||||||
|
|
||||||
providerPref.summary = provider.name
|
providerPref.summary = provider.name
|
||||||
|
autoLocationPref.isChecked = provider.autoLocation
|
||||||
if (provider.supportsAutoLocation) {
|
locationPref.summary =
|
||||||
autoLocationPref.setSummary(R.string.preference_automatic_location_summary)
|
if (provider.autoLocation) provider.getLastLocation()?.name else provider.getLocation()?.name
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onLookupCompleted(results: List<Pair<Any?, String>>) {
|
private fun onLookupCompleted(results: List<WeatherLocation>) {
|
||||||
MaterialDialog(requireContext())
|
MaterialDialog(requireContext())
|
||||||
.listItems(
|
.listItems(
|
||||||
items = results.map { it.second },
|
items = results.map { it.name },
|
||||||
waitForPositiveButton = false
|
waitForPositiveButton = false
|
||||||
) { dialog, index, _ ->
|
) { dialog, index, _ ->
|
||||||
val provider = WeatherProvider.getInstance(requireContext())
|
val provider = WeatherProvider.getInstance(requireContext())
|
||||||
?: return@listItems dialog.dismiss()
|
?: return@listItems dialog.dismiss()
|
||||||
provider.resetLastUpdate()
|
provider.resetLastUpdate()
|
||||||
provider.setLocation(results[index].first, results[index].second)
|
provider.setLocation(results[index])
|
||||||
findPreference<Preference>("location")?.summary = results[index].second
|
findPreference<Preference>("location")?.summary = results[index].name
|
||||||
ViewModelProvider(this).get(WeatherViewModel::class.java)
|
ViewModelProvider(this).get(WeatherViewModel::class.java)
|
||||||
.requestUpdate(requireContext())
|
.requestUpdate(requireContext())
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
|
|||||||
@ -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
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package de.mm20.launcher2.weather
|
||||||
|
|
||||||
|
interface WeatherLocation {
|
||||||
|
val name: String
|
||||||
|
}
|
||||||
@ -1,51 +1,118 @@
|
|||||||
package de.mm20.launcher2.weather
|
package de.mm20.launcher2.weather
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Context
|
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.LauncherPreferences
|
||||||
import de.mm20.launcher2.preferences.WeatherProviders
|
import de.mm20.launcher2.preferences.WeatherProviders
|
||||||
import de.mm20.launcher2.weather.here.HereProvider
|
import de.mm20.launcher2.weather.here.HereProvider
|
||||||
import de.mm20.launcher2.weather.metno.MetNoProvider
|
import de.mm20.launcher2.weather.metno.MetNoProvider
|
||||||
import de.mm20.launcher2.weather.openweathermap.OpenWeatherMapProvider
|
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 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.
|
* Lookup a location based on a string query.
|
||||||
* @param query the location to lookup
|
* @param query the location to lookup
|
||||||
* @return a list of Pair<Any?,String> with provider specific data of that location and its
|
* @return a list of locations
|
||||||
* display name
|
|
||||||
*/
|
*/
|
||||||
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 fun isAvailable(): Boolean
|
||||||
|
|
||||||
abstract val name: String
|
abstract val name: String
|
||||||
|
|
||||||
|
abstract fun getLastLocation(): T?
|
||||||
|
|
||||||
|
abstract fun saveLastLocation(location: T)
|
||||||
|
|
||||||
|
fun resetLastUpdate() {
|
||||||
|
preferences.edit {
|
||||||
|
putLong(LAST_UPDATE, 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun getInstance(context: Context): WeatherProvider? {
|
fun getInstance(context: Context): WeatherProvider<out WeatherLocation>? {
|
||||||
return when (LauncherPreferences.instance.weatherProvider) {
|
return when (LauncherPreferences.instance.weatherProvider) {
|
||||||
WeatherProviders.OPENWEATHERMAP -> OpenWeatherMapProvider(context)
|
WeatherProviders.OPENWEATHERMAP -> OpenWeatherMapProvider(context)
|
||||||
WeatherProviders.HERE -> HereProvider(context)
|
WeatherProviders.HERE -> HereProvider(context)
|
||||||
else -> MetNoProvider(context)
|
else -> MetNoProvider(context)
|
||||||
}.takeIf { it.isAvailable() }
|
}.takeIf { it.isAvailable() }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun getLastLocation(): String
|
private const val LAST_UPDATE = "last_update"
|
||||||
abstract fun resetLastUpdate()
|
private const val AUTO_LOCATION = "auto_location"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WeatherUpdateResult<T : WeatherLocation>(
|
||||||
|
val forecasts: List<Forecast>,
|
||||||
|
val location: T
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -1,117 +1,68 @@
|
|||||||
package de.mm20.launcher2.weather.here
|
package de.mm20.launcher2.weather.here
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.location.LocationManager
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||||
import de.mm20.launcher2.ktx.checkPermission
|
import de.mm20.launcher2.weather.*
|
||||||
import de.mm20.launcher2.ktx.getDouble
|
import retrofit2.Retrofit
|
||||||
import de.mm20.launcher2.ktx.putDouble
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import de.mm20.launcher2.weather.Forecast
|
import retrofit2.create
|
||||||
import de.mm20.launcher2.weather.R
|
|
||||||
import de.mm20.launcher2.weather.WeatherProvider
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import org.json.JSONException
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class HereProvider(val context: Context) : WeatherProvider() {
|
class HereProvider(override val context: Context) : LatLonWeatherProvider() {
|
||||||
override val supportsAutoLocation = true
|
|
||||||
override val supportsManualLocation = true
|
|
||||||
override var autoLocation: Boolean
|
|
||||||
get() {
|
|
||||||
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
.getBoolean(AUTO_LOCATION, true)
|
|
||||||
}
|
|
||||||
set(value) {
|
|
||||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putBoolean(AUTO_LOCATION, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun fetchNewWeatherData(): List<Forecast>? {
|
private val retrofit by lazy {
|
||||||
val prefs = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
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 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 lang = Locale.getDefault().language
|
||||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ROOT)
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ROOT)
|
||||||
|
|
||||||
val forecastList = mutableListOf<Forecast>()
|
val forecastList = mutableListOf<Forecast>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val httpClient = OkHttpClient()
|
val apiKey = getApiKey() ?: return null
|
||||||
|
|
||||||
val forecastRequest = Request.Builder()
|
val response = hereWeatherService.report(
|
||||||
.url("https://weather.ls.hereapi.com/weather/1.0/report.json?apiKey=${getApiKey()}&product=forecast_hourly&$query&language=$lang")
|
apiKey = apiKey,
|
||||||
.get()
|
language = lang,
|
||||||
.build()
|
latitude = lat,
|
||||||
|
longitude = lon
|
||||||
|
)
|
||||||
|
|
||||||
val body = withContext(Dispatchers.IO) {
|
val forecastLocation = response.hourlyForecasts?.forecastLocation ?: return null
|
||||||
httpClient.newCall(forecastRequest).execute().body?.string()
|
val forecasts = forecastLocation.forecast ?: return null
|
||||||
} ?: run {
|
|
||||||
Log.e("MM20", "Here provider: forecast request returned null")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val forecastLocation = JSONObject(body)
|
val location = forecastLocation.city ?: return null
|
||||||
.getJSONObject("hourlyForecasts")
|
|
||||||
.getJSONObject("forecastLocation")
|
|
||||||
val forecasts = forecastLocation.getJSONArray("forecast")
|
|
||||||
|
|
||||||
val location = forecastLocation.getString("city")
|
|
||||||
val locationLong =
|
|
||||||
"${forecastLocation.getString("city")}, ${forecastLocation.getString("country")}"
|
|
||||||
|
|
||||||
if (autoLocation) {
|
for (forecast in forecasts) {
|
||||||
prefs.edit {
|
|
||||||
putString(LAST_LOCATION, locationLong)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in 0 until forecasts.length()) {
|
|
||||||
val forecast = forecasts.getJSONObject(i)
|
|
||||||
|
|
||||||
val timestamp = try {
|
val timestamp = try {
|
||||||
dateFormat.parse(forecast.getString("utcTime"))?.time ?: continue
|
dateFormat.parse(forecast.utcTime ?: continue)?.time ?: continue
|
||||||
} catch (e: ParseException) {
|
} catch (e: ParseException) {
|
||||||
CrashReporter.logException(e)
|
CrashReporter.logException(e)
|
||||||
return null
|
return null
|
||||||
@ -121,24 +72,20 @@ class HereProvider(val context: Context) : WeatherProvider() {
|
|||||||
if (timestamp + 1000 * 60 * 30 < System.currentTimeMillis()) continue
|
if (timestamp + 1000 * 60 * 30 < System.currentTimeMillis()) continue
|
||||||
|
|
||||||
val condition = when {
|
val condition = when {
|
||||||
!forecast.optString("precipitationDesc")
|
!forecast.precipitationDesc.isNullOrEmpty() -> forecast.precipitationDesc
|
||||||
.isNullOrEmpty() -> forecast.optString("precipitationDesc")
|
!forecast.skyDescription.isNullOrEmpty() -> forecast.skyDescription
|
||||||
!forecast.optString("skyDescription")
|
!forecast.temperatureDesc.isNullOrEmpty() -> forecast.temperatureDesc
|
||||||
.isNullOrEmpty() -> forecast.optString("skyDescription")
|
else -> forecast.description ?: continue
|
||||||
!forecast.optString("temperatureDesc")
|
|
||||||
.isNullOrEmpty() -> forecast.optString("temperatureDesc")
|
|
||||||
else -> forecast.optString("description")
|
|
||||||
}
|
}
|
||||||
val humidity = forecast.getString("humidity").toIntOrNull() ?: 0
|
val humidity = forecast.humidity?.toIntOrNull() ?: 0
|
||||||
val icon = getIcon(forecast.getString("iconName"))
|
val icon = getIcon(forecast.iconName ?: continue)
|
||||||
val night = forecast.getString("daylight") == "N"
|
val night = forecast.daylight == "N"
|
||||||
val rain = forecast.getString("rainFall").toDoubleOrNull() ?: 0.0
|
val rain = forecast.rainFall?.toDoubleOrNull() ?: 0.0
|
||||||
val snow = forecast.getString("snowFall").toDoubleOrNull() ?: 0.0
|
val rainPercent = forecast.precipitationProbability?.toIntOrNull() ?: 0
|
||||||
val rainPercent = forecast.getString("precipitationProbability").toIntOrNull() ?: 0
|
val temperature = forecast.temperature?.toDoubleOrNull()?.plus(273.15)
|
||||||
val temperature = forecast.getString("temperature").toDoubleOrNull()?.plus(273.15)
|
|
||||||
?: 0.0
|
?: 0.0
|
||||||
val windDir = forecast.getString("windDirection").toIntOrNull() ?: 0
|
val windDir = forecast.windDirection?.toIntOrNull() ?: 0
|
||||||
val windSpeed = forecast.getString("windSpeed").toDoubleOrNull() ?: 0.0
|
val windSpeed = forecast.windSpeed?.toDoubleOrNull() ?: 0.0
|
||||||
|
|
||||||
forecastList.add(
|
forecastList.add(
|
||||||
Forecast(
|
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)
|
CrashReporter.logException(e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.edit {
|
|
||||||
putLong(LAST_UPDATE, updateTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
return forecastList
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun getIcon(iconName: String): Int {
|
private fun getIcon(iconName: String): Int {
|
||||||
with(Forecast) {
|
with(Forecast) {
|
||||||
return when (iconName) {
|
return when (iconName) {
|
||||||
@ -334,62 +285,29 @@ class HereProvider(val context: Context) : WeatherProvider() {
|
|||||||
return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis()
|
return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLastUpdate(): Long {
|
override suspend fun lookupLocation(query: String): List<LatLonWeatherLocation> {
|
||||||
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
val retrofit = Retrofit.Builder()
|
||||||
.getLong(LAST_UPDATE, 0)
|
.baseUrl("https://geocoder.ls.hereapi.com/6.2/")
|
||||||
}
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
|
||||||
override suspend fun lookupLocation(query: String): List<Pair<Any?, String>> {
|
|
||||||
val urlString =
|
|
||||||
"https://geocoder.ls.hereapi.com/6.2/geocode.json?apiKey=${getApiKey()}&searchtext=$query"
|
|
||||||
val client = OkHttpClient()
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(urlString)
|
|
||||||
.build()
|
.build()
|
||||||
|
val geocodeService = retrofit.create<HereGeocodeApi>()
|
||||||
try {
|
try {
|
||||||
val body = withContext(Dispatchers.IO) {
|
val apiKey = getApiKey() ?: return emptyList()
|
||||||
val response = client.newCall(request).execute()
|
val response = geocodeService.geocode(apiKey, query)
|
||||||
response.body?.string()
|
|
||||||
} ?: return emptyList()
|
return response.Response.View?.getOrNull(0)?.Result?.mapNotNull {
|
||||||
val json = JSONObject(body)
|
LatLonWeatherLocation(
|
||||||
val results = json
|
name = it.Location?.Address?.Label ?: return@mapNotNull null,
|
||||||
.optJSONObject("Response")
|
lat = it.Location.DisplayPosition?.Latitude ?: return@mapNotNull null,
|
||||||
?.optJSONArray("View")
|
lon = it.Location.DisplayPosition.Longitude ?: return@mapNotNull null,
|
||||||
?.optJSONObject(0)
|
)
|
||||||
?.optJSONArray("Result") ?: return emptyList()
|
} ?: emptyList()
|
||||||
val locations = mutableListOf<Pair<Any?, String>>()
|
} catch (e: Exception) {
|
||||||
for (i in 0 until results.length()) {
|
CrashReporter.logException(e)
|
||||||
val result = results.getJSONObject(i)
|
|
||||||
val location = result.optJSONObject("Location") ?: continue
|
|
||||||
val name = location.optJSONObject("Address")?.getString("Label") ?: continue
|
|
||||||
locations.add(URLEncoder.encode(name, "UTF-8") to name)
|
|
||||||
}
|
|
||||||
return locations
|
|
||||||
} catch (e: JSONException) {
|
|
||||||
} catch (e: IOException) {
|
|
||||||
}
|
}
|
||||||
return emptyList()
|
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? {
|
private fun getApiKey(): String? {
|
||||||
val resId = getApiKeyResId()
|
val resId = getApiKeyResId()
|
||||||
if (resId != 0) return context.getString(resId)
|
if (resId != 0) return context.getString(resId)
|
||||||
@ -410,11 +328,5 @@ class HereProvider(val context: Context) : WeatherProvider() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFERENCES = "here"
|
private const val PREFERENCES = "here"
|
||||||
private const val LAST_LAT = "last_lat"
|
|
||||||
private const val LAST_LON = "last_lon"
|
|
||||||
private const val LAST_UPDATE = "last_update"
|
|
||||||
private const val CITY_NAME = "city_name"
|
|
||||||
private const val LAST_LOCATION = "last_location"
|
|
||||||
private const val AUTO_LOCATION = "auto_location"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,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
|
||||||
|
}
|
||||||
@ -2,26 +2,23 @@ package de.mm20.launcher2.weather.metno
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.location.Geocoder
|
import android.location.Geocoder
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||||
import de.mm20.launcher2.ktx.checkPermission
|
import de.mm20.launcher2.ktx.checkPermission
|
||||||
import de.mm20.launcher2.ktx.formatToString
|
import de.mm20.launcher2.ktx.formatToString
|
||||||
import de.mm20.launcher2.ktx.getDouble
|
import de.mm20.launcher2.ktx.getDouble
|
||||||
import de.mm20.launcher2.ktx.putDouble
|
import de.mm20.launcher2.ktx.putDouble
|
||||||
import de.mm20.launcher2.weather.Forecast
|
import de.mm20.launcher2.weather.*
|
||||||
import de.mm20.launcher2.weather.R
|
|
||||||
import de.mm20.launcher2.weather.WeatherProvider
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.internal.userAgent
|
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.shredzone.commons.suncalc.SunTimes
|
import org.shredzone.commons.suncalc.SunTimes
|
||||||
@ -31,149 +28,10 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class MetNoProvider(val context: Context) : WeatherProvider() {
|
class MetNoProvider(override val context: Context) : LatLonWeatherProvider() {
|
||||||
override val supportsAutoLocation: Boolean
|
|
||||||
get() = true
|
|
||||||
override val supportsManualLocation: Boolean
|
|
||||||
get() = Geocoder.isPresent()
|
|
||||||
override var autoLocation: Boolean
|
|
||||||
get() {
|
|
||||||
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
.getBoolean(AUTO_LOCATION, true)
|
|
||||||
}
|
|
||||||
set(value) {
|
|
||||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putBoolean(AUTO_LOCATION, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun fetchNewWeatherData(): List<Forecast>? {
|
override val preferences: SharedPreferences
|
||||||
var lat: Double? = null
|
get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
var lon: Double? = null
|
|
||||||
var locationName: String? = null
|
|
||||||
val updateTime = System.currentTimeMillis()
|
|
||||||
val prefs = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val lastUpdate = prefs.getLong(LAST_UPDATE, 0L)
|
|
||||||
val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.ROOT)
|
|
||||||
val ifModifiedSince = httpDateFormat.format(Date(lastUpdate))
|
|
||||||
|
|
||||||
if (autoLocation &&
|
|
||||||
context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION)
|
|
||||||
) {
|
|
||||||
val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
|
||||||
val location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
|
|
||||||
lat = location?.latitude
|
|
||||||
lon = location?.longitude
|
|
||||||
if (Geocoder.isPresent() && lat != null && lon != null) {
|
|
||||||
try {
|
|
||||||
locationName = Geocoder(context).getFromLocation(lat, lon, 1)
|
|
||||||
.firstOrNull()
|
|
||||||
?.formatToString() ?: "$lat/$lon"
|
|
||||||
prefs.edit {
|
|
||||||
putString(LAST_LOCATION_NAME, locationName)
|
|
||||||
lat?.let { putDouble(LAST_LAT, it) }
|
|
||||||
lon?.let { putDouble(LAST_LON, it) }
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
CrashReporter.logException(e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!autoLocation) {
|
|
||||||
if (!prefs.contains(LON) || !prefs.contains(LAT)) return null
|
|
||||||
lat = prefs.getDouble(LAT)
|
|
||||||
lon = prefs.getDouble(LON)
|
|
||||||
|
|
||||||
locationName = prefs.getString(LAST_LOCATION_NAME, null) ?: "$lat/$lon"
|
|
||||||
}
|
|
||||||
if (lat == null || lon == null) {
|
|
||||||
if (!prefs.contains(LAST_LON) || !prefs.contains(LAST_LAT)) return null
|
|
||||||
lat = prefs.getDouble(LAST_LAT)
|
|
||||||
lon = prefs.getDouble(LAST_LON)
|
|
||||||
|
|
||||||
locationName = prefs.getString(LAST_LOCATION_NAME, null) ?: "$lat/$lon"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lat == null || lon == null || locationName == null) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
val forecasts = mutableListOf<Forecast>()
|
|
||||||
|
|
||||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT)
|
|
||||||
|
|
||||||
val httpClient = OkHttpClient()
|
|
||||||
|
|
||||||
val latParam = String.format(Locale.ROOT, "%.4f", lat)
|
|
||||||
val lonParam = String.format(Locale.ROOT, "%.4f", lon)
|
|
||||||
|
|
||||||
val forecastRequest = Request.Builder()
|
|
||||||
.url("https://api.met.no/weatherapi/locationforecast/2.0/?lat=$latParam&lon=$lonParam")
|
|
||||||
.addHeader("User-Agent", getUserAgent() ?: return null)
|
|
||||||
.addHeader("If-Modified-Since", ifModifiedSince)
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val response = httpClient.newCall(forecastRequest).execute()
|
|
||||||
val responseBody = response.body?.string() ?: return null
|
|
||||||
|
|
||||||
val json = JSONObject(responseBody)
|
|
||||||
val properties = json.getJSONObject("properties")
|
|
||||||
val meta = properties.getJSONObject("meta")
|
|
||||||
val updatedAt = dateFormat.parse(meta.getString("updated_at"))?.time
|
|
||||||
?: System.currentTimeMillis()
|
|
||||||
val timeseries = properties.getJSONArray("timeseries")
|
|
||||||
|
|
||||||
for (i in 0 until timeseries.length()) {
|
|
||||||
val fc = timeseries.getJSONObject(i)
|
|
||||||
val data = fc.getJSONObject("data")
|
|
||||||
val timestamp = dateFormat.parse(fc.getString("time"))?.time ?: continue
|
|
||||||
val details = data.getJSONObject("instant").getJSONObject("details")
|
|
||||||
var hours = 0
|
|
||||||
val nextHours = data.optJSONObject("next_1_hours")?.also { hours = 1 }
|
|
||||||
?: data.optJSONObject("next_6_hours")?.also { hours = 6 }
|
|
||||||
?: data.optJSONObject("next_12_hours")?.also { hours = 12 }
|
|
||||||
?: continue
|
|
||||||
val symbolCode = nextHours.optJSONObject("summary")?.getString("symbol_code")
|
|
||||||
?: continue
|
|
||||||
val precipitationAmount =
|
|
||||||
(nextHours.optJSONObject("details")?.optDouble("precipitation_amount")
|
|
||||||
?: 0.0) / hours
|
|
||||||
forecasts.add(
|
|
||||||
Forecast(
|
|
||||||
timestamp = timestamp,
|
|
||||||
temperature = details.getDouble("air_temperature") + 273.15,
|
|
||||||
updateTime = updatedAt,
|
|
||||||
clouds = details.getDouble("cloud_area_fraction").roundToInt(),
|
|
||||||
humidity = details.getDouble("relative_humidity"),
|
|
||||||
windDirection = details.getDouble("wind_from_direction"),
|
|
||||||
windSpeed = details.getDouble("wind_speed"),
|
|
||||||
pressure = details.getDouble("air_pressure_at_sea_level"),
|
|
||||||
location = locationName,
|
|
||||||
provider = context.getString(R.string.provider_metno),
|
|
||||||
providerUrl = "https://www.yr.no/",
|
|
||||||
icon = iconForCode(symbolCode),
|
|
||||||
condition = conditionForCode(symbolCode),
|
|
||||||
precipitation = precipitationAmount,
|
|
||||||
night = isNight(timestamp, lat, lon)
|
|
||||||
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
prefs.edit {
|
|
||||||
putLong(LAST_UPDATE, updateTime)
|
|
||||||
}
|
|
||||||
return forecasts
|
|
||||||
} catch (e: JSONException) {
|
|
||||||
CrashReporter.logException(e)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
CrashReporter.logException(e)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isNight(timestamp: Long, lat: Double, lon: Double): Boolean {
|
private fun isNight(timestamp: Long, lat: Double, lon: Double): Boolean {
|
||||||
val sunTimes = SunTimes.compute().on(Date(timestamp)).at(lat, lon).execute()
|
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 {
|
private fun conditionForCode(code: String): String {
|
||||||
return context.getString(
|
return context.getString(
|
||||||
when (code.substringBefore("_")) {
|
when (code.substringBefore("_")) {
|
||||||
@ -284,48 +138,6 @@ class MetNoProvider(val context: Context) : WeatherProvider() {
|
|||||||
return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis()
|
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? {
|
private fun getUserAgent(): String? {
|
||||||
val contactData = getContactInfo() ?: return null
|
val contactData = getContactInfo() ?: return null
|
||||||
|
|
||||||
@ -367,15 +179,87 @@ class MetNoProvider(val context: Context) : WeatherProvider() {
|
|||||||
return context.resources.getIdentifier("metno_contact", "string", context.packageName)
|
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 {
|
companion object {
|
||||||
private const val PREFERENCES = "metno"
|
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,48 +1,34 @@
|
|||||||
package de.mm20.launcher2.weather.openweathermap
|
package de.mm20.launcher2.weather.openweathermap
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.location.Location
|
import android.content.SharedPreferences
|
||||||
import android.location.LocationManager
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||||
import de.mm20.launcher2.ktx.checkPermission
|
import de.mm20.launcher2.weather.*
|
||||||
import de.mm20.launcher2.weather.Forecast
|
|
||||||
import de.mm20.launcher2.weather.R
|
|
||||||
import de.mm20.launcher2.weather.WeatherProvider
|
|
||||||
import retrofit2.HttpException
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import java.io.IOException
|
|
||||||
import java.lang.Exception
|
|
||||||
import java.util.*
|
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()
|
Retrofit.Builder()
|
||||||
.baseUrl("https://api.openweathermap.org/data/2.5/")
|
.baseUrl("https://api.openweathermap.org/data/2.5/")
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
val openWeatherMapService by lazy {
|
private val openWeatherMapService by lazy {
|
||||||
retrofit.create(OpenWeatherMapApi::class.java)
|
retrofit.create(OpenWeatherMapApi::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resetLastUpdate() {
|
|
||||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit {
|
|
||||||
putLong(LAST_UPDATE, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isUpdateRequired(): Boolean {
|
override fun isUpdateRequired(): Boolean {
|
||||||
return getLastUpdate() + (1000 * 60 * 60) <= System.currentTimeMillis()
|
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 lang = Locale.getDefault().language
|
||||||
|
|
||||||
val response = try {
|
val response = try {
|
||||||
@ -60,68 +46,37 @@ class OpenWeatherMapProvider(val context: Context) : WeatherProvider() {
|
|||||||
val country = response.sys?.country ?: ""
|
val country = response.sys?.country ?: ""
|
||||||
val cityId = response.id ?: return emptyList()
|
val cityId = response.id ?: return emptyList()
|
||||||
val loc = "$city, $country"
|
val loc = "$city, $country"
|
||||||
return listOf(cityId.toString() to loc)
|
return listOf(
|
||||||
|
OpenWeatherMapLocation(
|
||||||
|
name = loc, id = cityId
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setLocation(locationId: Any?, locationName: String) {
|
override suspend fun loadWeatherData(location: OpenWeatherMapLocation): WeatherUpdateResult<OpenWeatherMapLocation>? {
|
||||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit {
|
return fetchWeatherData(location = location)
|
||||||
putInt(CITY_ID, locationId as? Int ?: -1)
|
|
||||||
putString(LAST_LOCATION, locationName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun loadWeatherData(
|
||||||
override var autoLocation: Boolean
|
lat: Double,
|
||||||
get() {
|
lon: Double
|
||||||
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
): WeatherUpdateResult<OpenWeatherMapLocation>? {
|
||||||
.getBoolean(AUTO_LOCATION, true)
|
return fetchWeatherData(lat = lat, lon = lon)
|
||||||
}
|
|
||||||
set(value) {
|
|
||||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
.edit {
|
|
||||||
putBoolean(AUTO_LOCATION, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override val supportsAutoLocation: Boolean = true
|
|
||||||
override val supportsManualLocation: Boolean = true
|
|
||||||
|
|
||||||
override fun getLastUpdate(): Long {
|
|
||||||
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
.getLong(LAST_UPDATE, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLastLocation(): String {
|
private suspend fun fetchWeatherData(
|
||||||
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
lat: Double? = null,
|
||||||
.getString(LAST_LOCATION, "")!!
|
lon: Double? = null,
|
||||||
}
|
location: OpenWeatherMapLocation? = null
|
||||||
|
): WeatherUpdateResult<OpenWeatherMapLocation>? {
|
||||||
override suspend fun fetchNewWeatherData(): List<Forecast>? {
|
|
||||||
Log.d("MM20", "Updating weather data… (OpenWeatherMap)")
|
|
||||||
var cityId = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
.getInt(CITY_ID, -1)
|
|
||||||
val lastCityId = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
.getInt(LAST_CITY_ID, -1)
|
|
||||||
if (cityId == -1) cityId = lastCityId
|
|
||||||
val lm = context.getSystemService(
|
|
||||||
Context.LOCATION_SERVICE
|
|
||||||
) as LocationManager
|
|
||||||
var location: Location? = null
|
|
||||||
if (cityId == -1 && !context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) {
|
|
||||||
Log.w("MM20", "Location permission is missing")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION) && autoLocation) {
|
|
||||||
location = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
|
|
||||||
}
|
|
||||||
val lang = Locale.getDefault().language
|
val lang = Locale.getDefault().language
|
||||||
|
|
||||||
val currentWeather = try {
|
val currentWeather = try {
|
||||||
openWeatherMapService.currentWeather(
|
openWeatherMapService.currentWeather(
|
||||||
appid = getApiKey() ?: return null,
|
appid = getApiKey() ?: return null,
|
||||||
id = cityId.takeIf { it != -1 && location == null },
|
id = location?.id?.takeIf { lat == null || lon == null },
|
||||||
lat = location?.latitude,
|
lat = lat,
|
||||||
lon = location?.longitude,
|
lon = lon,
|
||||||
lang = lang,
|
lang = lang,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@ -133,7 +88,7 @@ class OpenWeatherMapProvider(val context: Context) : WeatherProvider() {
|
|||||||
|
|
||||||
val city = currentWeather.name
|
val city = currentWeather.name
|
||||||
val country = currentWeather.sys?.country ?: return null
|
val country = currentWeather.sys?.country ?: return null
|
||||||
cityId = currentWeather.id ?: return null
|
val cityId = currentWeather.id ?: return null
|
||||||
val loc = "$city, $country"
|
val loc = "$city, $country"
|
||||||
|
|
||||||
val forecasts = try {
|
val forecasts = try {
|
||||||
@ -200,20 +155,15 @@ class OpenWeatherMapProvider(val context: Context) : WeatherProvider() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
return WeatherUpdateResult(
|
||||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit {
|
forecasts = forecastList,
|
||||||
putInt(CITY_ID, cityId)
|
location = OpenWeatherMapLocation(
|
||||||
putInt(LAST_CITY_ID, cityId)
|
name = loc,
|
||||||
putLong(LAST_UPDATE, System.currentTimeMillis())
|
id = cityId
|
||||||
putString(LAST_LOCATION, loc)
|
)
|
||||||
}
|
)
|
||||||
|
|
||||||
return forecastList
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun getApiKey(): String? {
|
private fun getApiKey(): String? {
|
||||||
val resId = getApiKeyResId()
|
val resId = getApiKeyResId()
|
||||||
if (resId != 0) return context.getString(resId)
|
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 {
|
companion object {
|
||||||
private const val PREFERENCES = "openweathermap"
|
private const val PREFERENCES = "openweathermap"
|
||||||
private const val CITY_ID = "city_id"
|
private const val CITY_ID = "city_id"
|
||||||
private const val LAST_CITY_ID = "last_city_id"
|
private const val LAST_CITY_ID = "last_city_id"
|
||||||
private const val LAST_UPDATE = "last_update"
|
private const val LAST_UPDATE = "last_update"
|
||||||
|
private const val LOCATION = "location"
|
||||||
private const val LAST_LOCATION = "last_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
|
||||||
Loading…
x
Reference in New Issue
Block a user