Put Pit Log under version control so the app and sync server can live on git.

This commit is contained in:
RallyBlock committed 2026-10-22 13:50:38 -07:00
commit 1030bac79e
79 files changed
+8463

No files matched your search

@@ -0,0 +1,153 @@
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" +
"&current=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())
}
}
}