diff --git a/README.md b/README.md index 6f0b730..0a8de81 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Overcast reads from the source you choose in settings. API keys are kept on the | Visual Crossing | Yes | | | OpenWeatherMap | Yes | | | WeatherAPI.com | Yes | | +| Yandex Weather | Yes | Free key for personal, non-commercial use. Today and tomorrow only, 30 requests a day. No humidity or pressure. Yandex requires its attribution. | Alert coverage depends on the source. Not every provider returns alerts for every region. diff --git a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherDetailSheet.kt b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherDetailSheet.kt index 52e19c0..6c02aac 100644 --- a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherDetailSheet.kt +++ b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherDetailSheet.kt @@ -1024,7 +1024,7 @@ private class Detail(val slot: WeatherDetailSlot, val label: Int, val value: Str private fun DetailsSection(snapshot: WeatherSnapshot, units: WeatherUnits, palette: WeatherPalette, modifier: Modifier) { val extras = snapshot.extras val details = buildList { - add(Detail(WeatherDetailSlot.HUMIDITY, R.string.weather_detail_humidity, "${snapshot.humidity}%")) + snapshot.humidity?.let { add(Detail(WeatherDetailSlot.HUMIDITY, R.string.weather_detail_humidity, "$it%")) } add( Detail( WeatherDetailSlot.WIND, @@ -1033,7 +1033,7 @@ private fun DetailsSection(snapshot: WeatherSnapshot, units: WeatherUnits, palet ), ) extras?.windGustKph?.let { add(Detail(WeatherDetailSlot.GUSTS, R.string.weather_detail_gusts, WeatherFormat.wind(it, units.wind))) } - add(Detail(WeatherDetailSlot.RAIN_CHANCE, R.string.weather_detail_rain_chance, "${snapshot.chanceOfRain}%")) + snapshot.chanceOfRain?.let { add(Detail(WeatherDetailSlot.RAIN_CHANCE, R.string.weather_detail_rain_chance, "$it%")) } extras?.precipitationMm?.let { add(Detail(WeatherDetailSlot.PRECIPITATION, R.string.weather_detail_precipitation, WeatherFormat.precipitation(it, units.precipitation))) } @@ -1147,7 +1147,7 @@ private fun Footer( verticalArrangement = Arrangement.Center, ) { Text( - text = error?.let { errorLabel(context, it) } ?: WeatherProviders.byId(snapshot.providerId).displayName, + text = error?.let { errorLabel(context, it) } ?: providerLabel(snapshot.providerId), color = palette.accent, style = MaterialTheme.typography.titleSmall, maxLines = 1, @@ -1306,3 +1306,9 @@ internal fun AmbientClock(ambient: () -> Float, palette: WeatherPalette, modifie }, ) } + +@Composable +internal fun providerLabel(providerId: String): String { + val provider = WeatherProviders.byId(providerId) + return provider.attributionRes?.let { stringResource(it) } ?: provider.displayName +} diff --git a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherSettingsUI.kt b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherSettingsUI.kt index fc82404..7ab01aa 100644 --- a/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/overcast/ui/features/weather/WeatherSettingsUI.kt @@ -73,6 +73,7 @@ import com.sameerasw.overcast.weather.WindSpeedUnit import com.sameerasw.overcast.weather.model.WeatherError import com.sameerasw.overcast.weather.provider.OpenMeteoModels import com.sameerasw.overcast.weather.provider.WeatherProviders +import com.sameerasw.overcast.weather.work.WeatherScheduler import kotlinx.coroutines.launch @@ -130,6 +131,7 @@ fun WeatherSettingsUI( onClick = { if (option.id != providerId) { settings.setWeatherProvider(option.id) + WeatherScheduler.schedule(context, settings.getWeatherRefreshMinutes()) providerId = option.id savedKey = settings.getWeatherApiKey(option.id).orEmpty() keyInput = savedKey @@ -245,6 +247,7 @@ fun WeatherSettingsUI( onClick = { refreshMinutes = minutes settings.setWeatherRefreshMinutes(minutes) + WeatherScheduler.schedule(context, minutes) }, ) } diff --git a/app/src/main/java/com/sameerasw/overcast/weather/WeatherRepository.kt b/app/src/main/java/com/sameerasw/overcast/weather/WeatherRepository.kt index 5174042..9cc2a3e 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/WeatherRepository.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/WeatherRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import android.location.Location import androidx.annotation.Keep import com.google.gson.Gson +import java.util.Locale import com.sameerasw.overcast.data.repository.SettingsRepository import com.sameerasw.overcast.weather.location.DeviceLocationSource import com.sameerasw.overcast.weather.location.WeatherPlaces @@ -45,6 +46,7 @@ object WeatherRepository { val lastDeviceLocation: WeatherLocation? = null, val lastDeviceLocationAt: Long? = null, val notifiedAlertIds: Set? = null, + val lastFetchKey: String? = null, ) fun config(context: Context): WeatherConfig = SettingsWeatherConfig(SettingsRepository(context.applicationContext)) @@ -68,7 +70,8 @@ object WeatherRepository { fun isStale(context: Context): Boolean { val snapshot = _state.value.snapshot ?: return true - val interval = config(context).refreshIntervalMinutes * 60_000L + val settings = config(context) + val interval = maxOf(settings.refreshIntervalMinutes, WeatherProviders.byId(settings.providerId).minRefreshMinutes) * 60_000L return System.currentTimeMillis() - snapshot.updatedAt >= interval } @@ -99,9 +102,16 @@ object WeatherRepository { _state.update { it.copy(error = error, loading = false) } return@withLock false } + val fetchKey = "${provider.id}:${"%.2f".format(Locale.US, location.latitude)},${"%.2f".format(Locale.US, location.longitude)}" + if (provider.minRefreshMinutes > 0 && current != null && cache.lastFetchKey == fetchKey && + System.currentTimeMillis() - current.updatedAt < provider.minRefreshMinutes * 60_000L + ) { + _state.update { it.copy(loading = false) } + return@withLock false + } try { val snapshot = provider.fetch(location, config.apiKey) - cache = cache.copy(snapshot = snapshot) + cache = cache.copy(snapshot = snapshot, lastFetchKey = fetchKey) persist(app) _state.value = WeatherState(snapshot = snapshot) WeatherWidgetUpdater.updateAll(app) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandExpanded.kt b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandExpanded.kt index bb748af..26080c2 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandExpanded.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandExpanded.kt @@ -60,7 +60,7 @@ import com.sameerasw.overcast.weather.effects.WeatherEffects import com.sameerasw.overcast.weather.model.WeatherAlert import com.sameerasw.overcast.weather.model.WeatherError import com.sameerasw.overcast.weather.model.WeatherSnapshot -import com.sameerasw.overcast.weather.provider.WeatherProviders +import com.sameerasw.overcast.ui.features.weather.providerLabel import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Date @@ -151,9 +151,9 @@ fun WeatherIslandExpanded( WeatherIslandPayload.severeAlert(snapshot)?.let { AlertCard(it) } HourlyStrip(snapshot, units, iconStyle) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - DetailChip(R.drawable.rounded_water_drop_24, "${snapshot.humidity}%", Modifier.weight(1f)) + DetailChip(R.drawable.rounded_water_drop_24, snapshot.humidity?.let { "$it%" } ?: "–", Modifier.weight(1f)) DetailChip(R.drawable.rounded_air_24, WeatherFormat.wind(snapshot.windKph, units.wind), Modifier.weight(1f)) - DetailChip(R.drawable.rounded_rainy_24, "${snapshot.chanceOfRain}%", Modifier.weight(1f)) + DetailChip(R.drawable.rounded_rainy_24, snapshot.chanceOfRain?.let { "$it%" } ?: "–", Modifier.weight(1f)) } UpdatedRow(snapshot, state.loading, state.error, onKeepAlive, onRefresh) } @@ -280,7 +280,7 @@ private fun UpdatedRow( ).toString() Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text( - text = if (error != null) errorText(error) else "${WeatherProviders.byId(snapshot.providerId).displayName} - $age", + text = if (error != null) errorText(error) else "${providerLabel(snapshot.providerId)} - $age", style = body.copy(fontSize = 11.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandPayload.kt b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandPayload.kt index 0b8d2b1..da937e5 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandPayload.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/island/WeatherIslandPayload.kt @@ -96,7 +96,7 @@ object WeatherIslandPayload { onExpandedTap = IslandAction(IslandActionType.OPEN_APP), ), ) - val trailingId = if (alert != null) ICON_ALERT else RAIN_ICON.takeIf { snapshot.chanceOfRain > 0 } + val trailingId = if (alert != null) ICON_ALERT else RAIN_ICON.takeIf { (snapshot.chanceOfRain ?: 0) > 0 } if (alert == null && trailingId != null) { WeatherIslandIcons.bitmap(context, R.drawable.rounded_rainy_24)?.also { assets[RAIN_ICON] = it } } @@ -107,7 +107,7 @@ object WeatherIslandPayload { iconId = iconId, tone = if (style.tintable) IslandTone.ACCENT else IslandTone.NONE, trailingIconId = trailingId, - trailingText = "${snapshot.chanceOfRain}%".takeIf { alert == null && trailingId != null }, + trailingText = snapshot.chanceOfRain?.let { "$it%" }.takeIf { alert == null && trailingId != null }, trailingTone = if (alert != null) IslandTone.ALERT else IslandTone.ACCENT, pageHeightDp = if (alert != null) HEIGHT_ALERT_DP else HEIGHT_DP, ) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/model/WeatherModels.kt b/app/src/main/java/com/sameerasw/overcast/weather/model/WeatherModels.kt index e2c835a..249afe3 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/model/WeatherModels.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/model/WeatherModels.kt @@ -106,9 +106,9 @@ data class WeatherSnapshot( val condition: WeatherCondition, val conditionText: String, val isDay: Boolean, - val humidity: Int, + val humidity: Int?, val windKph: Double, - val chanceOfRain: Int, + val chanceOfRain: Int?, val highC: Double, val lowC: Double, val hourly: List, diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/ProviderHttp.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/ProviderHttp.kt index 780e9e1..c21f6d8 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/provider/ProviderHttp.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/ProviderHttp.kt @@ -16,7 +16,11 @@ internal object ProviderHttp { fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") - suspend fun get(url: String, headers: Map = emptyMap()): String = withContext(Dispatchers.IO) { + suspend fun get(url: String, headers: Map = emptyMap()): String = request(url, headers, null) + + suspend fun post(url: String, body: String, headers: Map = emptyMap()): String = request(url, headers, body) + + private suspend fun request(url: String, headers: Map, body: String?): String = withContext(Dispatchers.IO) { val connection = try { URL(url).openConnection() as HttpURLConnection } catch (e: IOException) { @@ -27,6 +31,12 @@ internal object ProviderHttp { connection.readTimeout = TIMEOUT_MS connection.setRequestProperty("User-Agent", USER_AGENT) headers.forEach { (k, v) -> connection.setRequestProperty(k, v) } + if (body != null) { + connection.requestMethod = "POST" + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/json") + connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + } val code = connection.responseCode if (code == HttpURLConnection.HTTP_UNAUTHORIZED || code == HttpURLConnection.HTTP_FORBIDDEN) { throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherProvider.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherProvider.kt index a8949a4..673cbd2 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherProvider.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/WeatherProvider.kt @@ -1,5 +1,6 @@ package com.sameerasw.overcast.weather.provider +import androidx.annotation.StringRes import com.sameerasw.overcast.weather.model.CityResult import com.sameerasw.overcast.weather.model.WeatherLocation import com.sameerasw.overcast.weather.model.WeatherSnapshot @@ -9,6 +10,9 @@ interface WeatherProvider { val displayName: String val requiresApiKey: Boolean val signupUrl: String? + // Minimum minutes between fetches for providers with a daily request cap; 0 means no limit. + val minRefreshMinutes: Int get() = 0 + @get:StringRes val attributionRes: Int? get() = null suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot @@ -30,6 +34,7 @@ object WeatherProviders { OpenWeatherMapProvider(), WeatherApiProvider(), AccuWeatherProvider(), + YandexWeatherProvider(), ) val default: WeatherProvider get() = all.first() diff --git a/app/src/main/java/com/sameerasw/overcast/weather/provider/YandexWeatherProvider.kt b/app/src/main/java/com/sameerasw/overcast/weather/provider/YandexWeatherProvider.kt new file mode 100644 index 0000000..5dc3e03 --- /dev/null +++ b/app/src/main/java/com/sameerasw/overcast/weather/provider/YandexWeatherProvider.kt @@ -0,0 +1,211 @@ +package com.sameerasw.overcast.weather.provider + +import com.sameerasw.overcast.R +import com.sameerasw.overcast.weather.model.CityResult +import com.sameerasw.overcast.weather.model.DailyForecast +import com.sameerasw.overcast.weather.model.HourlyForecast +import com.sameerasw.overcast.weather.model.WeatherCondition +import com.sameerasw.overcast.weather.model.WeatherExtras +import com.sameerasw.overcast.weather.model.WeatherLocation +import com.sameerasw.overcast.weather.model.WeatherSnapshot +import org.json.JSONException +import org.json.JSONObject +import kotlin.math.roundToInt + +// Yandex Weather API v3 (GraphQL), the free tariff for personal, non-commercial use. +// Terms: https://yandex.ru/legal/apib2c_weather_agreement/ru/ (points 2.2, 2.4, 2.6, 2.7). +// Point 2.7 lists temperature, feels-like, wind, condition and icon. The owner also requests the other +// fields the key returns (rain chance, UV, gusts, wind direction, sunrise and sunset): see the commit message. +class YandexWeatherProvider : WeatherProvider { + override val id = "yandex" + override val displayName = "Yandex Weather" + override val requiresApiKey = true + override val signupUrl = "https://yandex.ru/pogoda/b2b/console/smarthome" + // The tariff allows 30 requests a day. A 50-minute gap keeps any rolling day under that; the repository enforces it. + override val minRefreshMinutes = 50 + override val attributionRes = R.string.weather_attribution_yandex + + override suspend fun fetch(location: WeatherLocation, apiKey: String?): WeatherSnapshot { + val key = apiKey?.trim().orEmpty() + if (key.isEmpty()) throw WeatherProviderException(WeatherProviderException.Reason.INVALID_KEY) + val body = JSONObject().put("query", graphQuery(location)).toString() + val json = try { + JSONObject(ProviderHttp.post(BASE_URL, body, mapOf(KEY_HEADER to key))) + } catch (e: JSONException) { + throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) + } + throwOnApiErrors(json) + val place = location.name?.let { it to "" } ?: ProviderHttp.reverseGeocode(location.latitude, location.longitude) + return try { + parseForecast(json.getJSONObject("data").getJSONObject("weatherByPoint"), place) + } catch (e: JSONException) { + throw WeatherProviderException(WeatherProviderException.Reason.BAD_RESPONSE, e.message) + } + } + + override suspend fun searchCities(query: String, apiKey: String?): List = ProviderHttp.searchCities(query) + + // GraphQL reports failures with HTTP 200 and an errors array instead of data. + private fun throwOnApiErrors(json: JSONObject) { + if (!json.isNull("data")) return + val message = json.optJSONArray("errors")?.optJSONObject(0)?.optString("message").orEmpty() + val reason = if (AUTH_ERROR.containsMatchIn(message)) { + WeatherProviderException.Reason.INVALID_KEY + } else { + WeatherProviderException.Reason.BAD_RESPONSE + } + throw WeatherProviderException(reason, message.ifBlank { null }) + } + + // Point 2.7 parameters plus precProbability (see the header). The forecast covers today and tomorrow. + private fun graphQuery(location: WeatherLocation) = """ + { + weatherByPoint(request: { lat: ${coordinate(location.latitude)}, lon: ${coordinate(location.longitude)} }) { + now { + temperature(unit: CELSIUS) + feelsLike(unit: CELSIUS) + windSpeed(unit: KILOMETERS_PER_HOUR) + windGust(unit: KILOMETERS_PER_HOUR) + windDirection + uvIndex + condition + icon(format: CODE) + } + forecast { + days(limit: 2) { + timestamp + sunriseTimestamp + sunsetTimestamp + hours { + timestamp + temperature(unit: CELSIUS) + condition + icon(format: CODE) + precProbability + } + } + } + } + } + """.trimIndent() + + // Two decimals is about 1 km, which keeps the exact position off the wire. + private fun coordinate(value: Double): Double = (value * 100).roundToInt() / 100.0 + + private fun parseForecast(weather: JSONObject, place: Pair?): WeatherSnapshot { + val now = weather.getJSONObject("now") + val days = weather.getJSONObject("forecast").getJSONArray("days") + val nowMillis = System.currentTimeMillis() + val daily = (0 until days.length()).mapNotNull { dayFrom(days.getJSONObject(it)) } + val today = daily.firstOrNull() + val extras = WeatherExtras( + uvIndex = now.optDouble("uvIndex").takeUnless { it.isNaN() }, + windGustKph = now.optDouble("windGust").takeUnless { it.isNaN() }, + windDirectionDeg = windDirectionDeg(now.optString("windDirection")), + sunriseMillis = days.getJSONObject(0).optLong("sunriseTimestamp").takeIf { it > 0 }?.times(1000L), + sunsetMillis = days.getJSONObject(0).optLong("sunsetTimestamp").takeIf { it > 0 }?.times(1000L), + ) + + val hourly = buildList { + for (d in 0 until days.length()) { + val hours = days.getJSONObject(d).getJSONArray("hours") + for (h in 0 until hours.length()) { + val hour = hours.getJSONObject(h) + val timestamp = hour.getLong("timestamp") + if ((timestamp + HOUR_S) * 1000L <= nowMillis) continue + add( + HourlyForecast( + timeMillis = timestamp * 1000L, + tempC = hour.getDouble("temperature"), + condition = conditionFor(hour.getString("condition")), + isDay = isDaytime(timestamp, days.getJSONObject(d), hour.optString("icon")), + chanceOfRain = percent(hour.optDouble("precProbability")), + ), + ) + } + } + }.take(HOURLY_COUNT) + + return WeatherSnapshot( + locationName = place?.first.orEmpty(), + region = place?.second.orEmpty(), + tempC = now.getDouble("temperature"), + feelsLikeC = now.getDouble("feelsLike"), + condition = conditionFor(now.getString("condition")), + conditionText = now.getString("condition"), + isDay = isDaytime(nowMillis / 1000L, days.getJSONObject(0), now.optString("icon")), + humidity = null, + windKph = now.getDouble("windSpeed"), + chanceOfRain = today?.chanceOfRain, + highC = today?.highC ?: now.getDouble("temperature"), + lowC = today?.lowC ?: now.getDouble("temperature"), + hourly = hourly, + alerts = emptyList(), + updatedAt = nowMillis, + providerId = id, + extras = extras, + daily = daily, + ) + } + + // Highs and lows come from the hourly temperatures, since the free tariff has no daily summary. + private fun dayFrom(day: JSONObject): DailyForecast? { + val hours = day.getJSONArray("hours") + if (hours.length() == 0) return null + val temps = (0 until hours.length()).map { hours.getJSONObject(it).getDouble("temperature") } + val midday = hours.getJSONObject(minOf(12, hours.length() - 1)) + return DailyForecast( + dayMillis = day.getLong("timestamp") * 1000L, + highC = temps.max(), + lowC = temps.min(), + condition = conditionFor(midday.getString("condition")), + chanceOfRain = (0 until hours.length()).maxOf { percent(hours.getJSONObject(it).optDouble("precProbability")) }, + ) + } + + // The free tariff has no daytime field, so the day's sunrise and sunset decide; night icons end in "_n", for example "skc_n", as a fallback. + private fun isDaytime(timestampS: Long, day: JSONObject, icon: String): Boolean { + val sunrise = day.optLong("sunriseTimestamp") + val sunset = day.optLong("sunsetTimestamp") + return if (sunrise > 0 && sunset > sunrise) timestampS in sunrise until sunset else !icon.endsWith("_n") + } + + // precProbability is a 0..1 chance; a missing field (NaN) counts as 0. + private fun percent(probability: Double): Int = if (probability.isNaN()) 0 else (probability * 100).roundToInt() + + // Yandex sends a compass point; CALM has no direction. + private fun windDirectionDeg(code: String): Double? = when (code) { + "NORTH" -> 0.0 + "NORTH_EAST" -> 45.0 + "EAST" -> 90.0 + "SOUTH_EAST" -> 135.0 + "SOUTH" -> 180.0 + "SOUTH_WEST" -> 225.0 + "WEST" -> 270.0 + "NORTH_WEST" -> 315.0 + else -> null + } + + // https://yandex.ru/dev/weather/doc/ru/concepts/spectaql.md (the Condition enum) + private fun conditionFor(code: String): WeatherCondition = when (code) { + "CLEAR" -> WeatherCondition.CLEAR + "PARTLY_CLOUDY" -> WeatherCondition.PARTLY_CLOUDY + "CLOUDY", "OVERCAST" -> WeatherCondition.CLOUDY + "LIGHT_RAIN" -> WeatherCondition.DRIZZLE + "RAIN", "SHOWERS" -> WeatherCondition.RAIN + "HEAVY_RAIN" -> WeatherCondition.HEAVY_RAIN + "SLEET" -> WeatherCondition.SLEET + "LIGHT_SNOW", "SNOW", "SNOWFALL" -> WeatherCondition.SNOW + "HAIL" -> WeatherCondition.HAIL + "THUNDERSTORM", "THUNDERSTORM_WITH_RAIN", "THUNDERSTORM_WITH_HAIL" -> WeatherCondition.THUNDERSTORM + else -> WeatherCondition.UNKNOWN + } + + private companion object { + const val BASE_URL = "https://api.weather.yandex.ru/graphql/query" + const val KEY_HEADER = "X-Yandex-Weather-Key" + const val HOUR_S = 60 * 60L + const val HOURLY_COUNT = 24 + val AUTH_ERROR = Regex("unauthori[sz]ed|forbidden|api key|invalid key|access denied", RegexOption.IGNORE_CASE) + } +} diff --git a/app/src/main/java/com/sameerasw/overcast/weather/work/WeatherRefreshWorker.kt b/app/src/main/java/com/sameerasw/overcast/weather/work/WeatherRefreshWorker.kt index 221e4db..54df838 100644 --- a/app/src/main/java/com/sameerasw/overcast/weather/work/WeatherRefreshWorker.kt +++ b/app/src/main/java/com/sameerasw/overcast/weather/work/WeatherRefreshWorker.kt @@ -8,7 +8,9 @@ import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.sameerasw.overcast.data.repository.SettingsRepository import com.sameerasw.overcast.weather.WeatherRepository +import com.sameerasw.overcast.weather.provider.WeatherProviders import java.util.concurrent.TimeUnit class WeatherRefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { @@ -22,7 +24,10 @@ object WeatherScheduler { private const val WORK_NAME = "weather_refresh" fun schedule(context: Context, intervalMinutes: Int) { - val request = PeriodicWorkRequestBuilder(intervalMinutes.coerceAtLeast(15).toLong(), TimeUnit.MINUTES) + // Yandex allows 30 requests a day, so its provider sets a longer minimum. + val provider = WeatherProviders.byId(SettingsRepository(context).getWeatherProvider()) + val minutes = maxOf(intervalMinutes, 15, provider.minRefreshMinutes) + val request = PeriodicWorkRequestBuilder(minutes.toLong(), TimeUnit.MINUTES) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 283cba4..e5b92f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,4 +153,6 @@ Finish Reset welcome Welcome will show again on next launch + + The data is provided by the Yandex Weather service.