154 lines
5.4 KiB
Kotlin
154 lines
5.4 KiB
Kotlin
package com.racingapp.pitlog.model
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.content.Context
|
|
import android.location.Location
|
|
import android.location.LocationListener
|
|
import android.location.LocationManager
|
|
import android.os.Build
|
|
import android.os.CancellationSignal
|
|
import android.os.Looper
|
|
import java.net.HttpURLConnection
|
|
import java.net.URL
|
|
import kotlin.coroutines.resume
|
|
import kotlin.math.roundToInt
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
import kotlinx.coroutines.withContext
|
|
import kotlinx.coroutines.withTimeoutOrNull
|
|
|
|
data class LocalWeather(
|
|
val airF: Int,
|
|
val summary: String,
|
|
)
|
|
|
|
class WeatherUnavailable(message: String) : Exception(message)
|
|
|
|
suspend fun readLocalWeather(context: Context): LocalWeather {
|
|
val location = locate(context)
|
|
val body = withContext(Dispatchers.IO) {
|
|
fetchForecast(location.latitude, location.longitude)
|
|
}
|
|
return parseOpenMeteo(body)
|
|
}
|
|
|
|
internal fun parseOpenMeteo(body: String): LocalWeather {
|
|
val temp = numberAfter(body, "temperature_2m") ?: throw WeatherUnavailable("Couldn't read the weather here.")
|
|
val code = numberAfter(body, "weather_code")?.toInt() ?: -1
|
|
return LocalWeather(temp.roundToInt(), weatherWords(code))
|
|
}
|
|
|
|
internal fun weatherWords(code: Int): String = when (code) {
|
|
0 -> "Clear"
|
|
1 -> "Mostly clear"
|
|
2 -> "Partly cloudy"
|
|
3 -> "Overcast"
|
|
45, 48 -> "Fog"
|
|
51, 53, 55, 56, 57 -> "Drizzle"
|
|
61, 63, 65, 66, 67 -> "Rain"
|
|
71, 73, 75, 77 -> "Snow"
|
|
80, 81, 82 -> "Rain showers"
|
|
85, 86 -> "Snow showers"
|
|
95, 96, 99 -> "Thunderstorm"
|
|
else -> "Weather"
|
|
}
|
|
|
|
private fun numberAfter(body: String, key: String): Double? {
|
|
val marker = "\"$key\""
|
|
var search = 0
|
|
while (search < body.length) {
|
|
val start = body.indexOf(marker, search)
|
|
if (start < 0) return null
|
|
val colon = body.indexOf(':', start + marker.length)
|
|
if (colon < 0) return null
|
|
val slice = body.substring(colon + 1).trimStart()
|
|
val end = slice.indexOfFirst { it != '-' && it != '.' && !it.isDigit() }
|
|
val token = if (end < 0) slice else slice.substring(0, end)
|
|
val number = token.toDoubleOrNull()
|
|
if (number != null) return number
|
|
search = colon + 1
|
|
}
|
|
return null
|
|
}
|
|
|
|
private fun fetchForecast(latitude: Double, longitude: Double): String {
|
|
val url = URL(
|
|
"https://api.open-meteo.com/v1/forecast" +
|
|
"?latitude=$latitude&longitude=$longitude" +
|
|
"¤t=temperature_2m,weather_code&temperature_unit=fahrenheit",
|
|
)
|
|
val connection = (url.openConnection() as HttpURLConnection).apply {
|
|
connectTimeout = 8_000
|
|
readTimeout = 8_000
|
|
requestMethod = "GET"
|
|
setRequestProperty("User-Agent", "PitLog")
|
|
}
|
|
try {
|
|
if (connection.responseCode !in 200..299) {
|
|
throw WeatherUnavailable("Couldn't read the weather here.")
|
|
}
|
|
return connection.inputStream.bufferedReader().use { it.readText() }
|
|
} finally {
|
|
connection.disconnect()
|
|
}
|
|
}
|
|
|
|
@SuppressLint("MissingPermission")
|
|
private suspend fun locate(context: Context): Location {
|
|
val manager = context.getSystemService(LocationManager::class.java)
|
|
?: throw WeatherUnavailable("Turn on location so the air temperature can be filled in.")
|
|
val providers = locationProviders(manager)
|
|
if (providers.isEmpty()) {
|
|
throw WeatherUnavailable("Turn on location so the air temperature can be filled in.")
|
|
}
|
|
val recent = providers.firstNotNullOfOrNull { provider ->
|
|
runCatching { manager.getLastKnownLocation(provider) }.getOrNull()
|
|
}
|
|
if (recent != null && System.currentTimeMillis() - recent.time < 30 * 60 * 1000) return recent
|
|
for (provider in providers) {
|
|
val fresh = try {
|
|
requestLocation(manager, provider)
|
|
} catch (_: SecurityException) {
|
|
null
|
|
}
|
|
if (fresh != null) return fresh
|
|
}
|
|
return recent
|
|
?: throw WeatherUnavailable("Turn on location so the air temperature can be filled in.")
|
|
}
|
|
|
|
private fun locationProviders(manager: LocationManager): List<String> {
|
|
val names = buildList {
|
|
if (Build.VERSION.SDK_INT >= 31) add(LocationManager.FUSED_PROVIDER)
|
|
add(LocationManager.NETWORK_PROVIDER)
|
|
add(LocationManager.GPS_PROVIDER)
|
|
}
|
|
return names.filter { provider ->
|
|
runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false)
|
|
}
|
|
}
|
|
|
|
@SuppressLint("MissingPermission")
|
|
private suspend fun requestLocation(manager: LocationManager, provider: String): Location? = withTimeoutOrNull(8_000) {
|
|
if (Build.VERSION.SDK_INT >= 30) {
|
|
suspendCancellableCoroutine { cont ->
|
|
val signal = CancellationSignal()
|
|
cont.invokeOnCancellation { signal.cancel() }
|
|
manager.getCurrentLocation(provider, signal, Runnable::run) { location ->
|
|
if (cont.isActive) cont.resume(location)
|
|
}
|
|
}
|
|
} else {
|
|
suspendCancellableCoroutine { cont ->
|
|
val listener = object : LocationListener {
|
|
override fun onLocationChanged(location: Location) {
|
|
manager.removeUpdates(this)
|
|
if (cont.isActive) cont.resume(location)
|
|
}
|
|
}
|
|
cont.invokeOnCancellation { manager.removeUpdates(listener) }
|
|
manager.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper())
|
|
}
|
|
}
|
|
}
|