diff --git a/app/src/main/java/de/mm20/launcher2/fragment/PreferencesWeatherFragment.kt b/app/src/main/java/de/mm20/launcher2/fragment/PreferencesWeatherFragment.kt index 17da14e2..ee60fa3b 100644 --- a/app/src/main/java/de/mm20/launcher2/fragment/PreferencesWeatherFragment.kt +++ b/app/src/main/java/de/mm20/launcher2/fragment/PreferencesWeatherFragment.kt @@ -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("imperial_units")!! val providerPref = findPreference("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>) { + private fun onLookupCompleted(results: List) { 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("location")?.summary = results[index].second + provider.setLocation(results[index]) + findPreference("location")?.summary = results[index].name ViewModelProvider(this).get(WeatherViewModel::class.java) .requestUpdate(requireContext()) dialog.dismiss() diff --git a/weather/src/main/java/de/mm20/launcher2/weather/LatLonWeatherProvider.kt b/weather/src/main/java/de/mm20/launcher2/weather/LatLonWeatherProvider.kt new file mode 100644 index 00000000..0f0aa74b --- /dev/null +++ b/weather/src/main/java/de/mm20/launcher2/weather/LatLonWeatherProvider.kt @@ -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() { + + + override suspend fun lookupLocation(query: String): List { + 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? { + 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 \ No newline at end of file diff --git a/weather/src/main/java/de/mm20/launcher2/weather/WeatherLocation.kt b/weather/src/main/java/de/mm20/launcher2/weather/WeatherLocation.kt new file mode 100644 index 00000000..c8f31b52 --- /dev/null +++ b/weather/src/main/java/de/mm20/launcher2/weather/WeatherLocation.kt @@ -0,0 +1,5 @@ +package de.mm20.launcher2.weather + +interface WeatherLocation { + val name: String +} \ No newline at end of file diff --git a/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt b/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt index 2dab9149..4ca0ec62 100644 --- a/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt +++ b/weather/src/main/java/de/mm20/launcher2/weather/WeatherProvider.kt @@ -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 { - 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? + + suspend fun fetchNewWeatherData(): List? { + val result: WeatherUpdateResult + if (autoLocation) { + if (context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION)) { + val lm = context.getSystemService()!! + 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? + internal abstract suspend fun loadWeatherData(lat: Double, lon: Double): WeatherUpdateResult? 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 with provider specific data of that location and its - * display name + * @return a list of locations */ - abstract suspend fun lookupLocation(query: String): List> + abstract suspend fun lookupLocation(query: String): List - 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? { 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() -} \ No newline at end of file + private const val LAST_UPDATE = "last_update" + private const val AUTO_LOCATION = "auto_location" + } +} + +data class WeatherUpdateResult( + val forecasts: List, + val location: T +) \ No newline at end of file diff --git a/weather/src/main/java/de/mm20/launcher2/weather/here/HereGeocodeApi.kt b/weather/src/main/java/de/mm20/launcher2/weather/here/HereGeocodeApi.kt new file mode 100644 index 00000000..fe5b2d00 --- /dev/null +++ b/weather/src/main/java/de/mm20/launcher2/weather/here/HereGeocodeApi.kt @@ -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? +) + +data class HereGeocodeResultResponseView( + val Result: Array? +) + +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 +} \ No newline at end of file diff --git a/weather/src/main/java/de/mm20/launcher2/weather/here/HereProvider.kt b/weather/src/main/java/de/mm20/launcher2/weather/here/HereProvider.kt index 83951446..def6050d 100644 --- a/weather/src/main/java/de/mm20/launcher2/weather/here/HereProvider.kt +++ b/weather/src/main/java/de/mm20/launcher2/weather/here/HereProvider.kt @@ -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? { - 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() + } + + override val preferences: SharedPreferences + get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + + override suspend fun loadWeatherData(location: LatLonWeatherLocation): WeatherUpdateResult? { + return loadWeatherData(location.lat, location.lon) + } + + override suspend fun loadWeatherData( + lat: Double, + lon: Double + ): WeatherUpdateResult? { 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() 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> { - 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 { + val retrofit = Retrofit.Builder() + .baseUrl("https://geocoder.ls.hereapi.com/6.2/") + .addConverterFactory(GsonConverterFactory.create()) .build() + val geocodeService = retrofit.create() 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>() - 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" } -} \ No newline at end of file +} diff --git a/weather/src/main/java/de/mm20/launcher2/weather/here/HereWeatherApi.kt b/weather/src/main/java/de/mm20/launcher2/weather/here/HereWeatherApi.kt new file mode 100644 index 00000000..75cce93e --- /dev/null +++ b/weather/src/main/java/de/mm20/launcher2/weather/here/HereWeatherApi.kt @@ -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?, + 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 +} \ No newline at end of file diff --git a/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt b/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt index 3d0f3579..989d896a 100644 --- a/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt +++ b/weather/src/main/java/de/mm20/launcher2/weather/metno/MetNoProvider.kt @@ -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? { - 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() - - 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> { - 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 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? { + + 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() + + 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" } } \ No newline at end of file diff --git a/weather/src/main/java/de/mm20/launcher2/weather/openweathermap/OpenWeatherMapProvider.kt b/weather/src/main/java/de/mm20/launcher2/weather/openweathermap/OpenWeatherMapProvider.kt index bab6e667..af88268c 100644 --- a/weather/src/main/java/de/mm20/launcher2/weather/openweathermap/OpenWeatherMapProvider.kt +++ b/weather/src/main/java/de/mm20/launcher2/weather/openweathermap/OpenWeatherMapProvider.kt @@ -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() { - 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> { + override suspend fun lookupLocation(query: String): List { 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? { + 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? { + 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? { - 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? { 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" } -} \ No newline at end of file +} + +data class OpenWeatherMapLocation( + override val name: String, + val id: Int +) : WeatherLocation \ No newline at end of file