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 { 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()) } } }