package de.upapp import android.content.SharedPreferences import android.util.Log import androidx.core.content.edit import org.bouncycastle.tls.AlertDescription import org.bouncycastle.tls.TlsClientProtocol import org.bouncycastle.tls.TlsFatalAlertReceived import org.json.JSONObject import java.io.IOException // Host headers as maps+more sends them through its local port mapping. private const val HTTP_HOST = "127.0.0.1:4080" internal const val HTTPS_HOST = "127.0.0.1:4443" // The car answers only after the user confirms the PIN on its display. private const val REGISTRATION_TIMEOUT_MS = 120_000L /** REST access to the car. [credentials] stores one `user,password` pair per VIN. */ class Car(internal val tunnel: Tunnel, private val credentials: SharedPreferences) { lateinit var vin: String private set internal lateinit var psk: Pair private set /** Reads the VIN, registers if needed and opens a session. [onPin] gets the PIN that the car display shows. */ fun connect(onPin: (String) -> Unit) { val r = plain("GET", "/car/info/vin") if (r.status != 200) throw IOException("Reading the VIN failed: HTTP ${r.status}") vin = r.text.trim() val stored = credentials.getString(vin, null) psk = parseCredentials(stored ?: register(onPin)) if (stored == null) credentials.edit { putString(vin, "${psk.first},${psk.second}") } try { get("/") } catch (e: TlsFatalAlertReceived) { credentials.edit { remove(vin) } throw IOException("The car rejected the stored key (${AlertDescription.getText(e.alertDescription)}). Connect again to register.", e) } } fun get(path: String): String = send("GET", path).ok().text fun post(path: String, json: JSONObject): String = send("POST", path, json.toString().encodeToByteArray()).ok().text /** Raw request over the authenticated session. Does not check the status. */ @Synchronized fun send(method: String, path: String, body: ByteArray? = null): Response { val reused = session != null val s = session ?: Session(tunnel.open(443)) session = null val r = try { exchange(s.tls.inputStream, s.tls.outputStream, method, HTTPS_HOST, "/" + path.trimStart('/'), body, keepAlive = true) } catch (e: IOException) { s.close() // The car may have closed the idle connection. The retry opens a fresh one. if (reused) return send(method, path, body) throw e } if (r.reusable) session = s else s.close() if (BuildConfig.DEBUG) Log.d("Http", "$method $path -> ${r.status} ${r.text.take(3000)}") return r } private var session: Session? = null private inner class Session(val channel: Tunnel.Channel) { val tls = try { sessionTls(channel.input, channel.output, psk.first, psk.second) } catch (e: IOException) { channel.close() throw e } fun close() { runCatching { tls.close() } channel.close() } } private fun register(onPin: (String) -> Unit): String { val r = plain("POST", "/auth/registration", ByteArray(0)) if (r.status != 303) throw IOException("Registration failed: expected HTTP 303, got ${r.status}") val location = r.headers["content-location"] ?: throw IOException("Registration failed: no content-location header") return tunnel.open(443, REGISTRATION_TIMEOUT_MS).use { ch -> val (tls, pin) = registrationTls(ch.input, ch.output) onPin(pin) tls.exchangeAndClose("GET", location, null) }.ok().text } private fun plain(method: String, path: String, body: ByteArray? = null) = tunnel.open(80).use { exchange(it.input, it.output, method, HTTP_HOST, path, body) } private fun TlsClientProtocol.exchangeAndClose(method: String, path: String, body: ByteArray?) = try { exchange(inputStream, outputStream, method, HTTPS_HOST, path, body) } finally { runCatching { close() } } internal fun Response.ok() = apply { if (status !in 200..299) throw IOException("HTTP $status: ${text.take(200)}") } } internal fun parseCredentials(s: String): Pair { val parts = s.trim().split(',') if (parts.size != 2) throw IOException("Invalid credentials from the car") return parts[0] to parts[1] }