Car.kt
| 1 | package de.upapp |
| 2 | |
| 3 | import android.content.SharedPreferences |
| 4 | import android.util.Log |
| 5 | import androidx.core.content.edit |
| 6 | import org.bouncycastle.tls.AlertDescription |
| 7 | import org.bouncycastle.tls.TlsClientProtocol |
| 8 | import org.bouncycastle.tls.TlsFatalAlertReceived |
| 9 | import org.json.JSONObject |
| 10 | import java.io.IOException |
| 11 | |
| 12 | // Host headers as maps+more sends them through its local port mapping. |
| 13 | private const val HTTP_HOST = "127.0.0.1:4080" |
| 14 | internal const val HTTPS_HOST = "127.0.0.1:4443" |
| 15 | // The car answers only after the user confirms the PIN on its display. |
| 16 | private const val REGISTRATION_TIMEOUT_MS = 120_000L |
| 17 | |
| 18 | /** REST access to the car. [credentials] stores one `user,password` pair per VIN. */ |
| 19 | class Car(internal val tunnel: Tunnel, private val credentials: SharedPreferences) { |
| 20 | lateinit var vin: String |
| 21 | private set |
| 22 | internal lateinit var psk: Pair<String, String> |
| 23 | private set |
| 24 | |
| 25 | /** Reads the VIN, registers if needed and opens a session. [onPin] gets the PIN that the car display shows. */ |
| 26 | fun connect(onPin: (String) -> Unit) { |
| 27 | val r = plain("GET", "/car/info/vin") |
| 28 | if (r.status != 200) throw IOException("Reading the VIN failed: HTTP ${r.status}") |
| 29 | vin = r.text.trim() |
| 30 | val stored = credentials.getString(vin, null) |
| 31 | psk = parseCredentials(stored ?: register(onPin)) |
| 32 | if (stored == null) credentials.edit { putString(vin, "${psk.first},${psk.second}") } |
| 33 | try { |
| 34 | get("/") |
| 35 | } catch (e: TlsFatalAlertReceived) { |
| 36 | credentials.edit { remove(vin) } |
| 37 | throw IOException("The car rejected the stored key (${AlertDescription.getText(e.alertDescription)}). Connect again to register.", e) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | fun get(path: String): String = send("GET", path).ok().text |
| 42 | |
| 43 | fun post(path: String, json: JSONObject): String = send("POST", path, json.toString().encodeToByteArray()).ok().text |
| 44 | |
| 45 | /** Raw request over the authenticated session. Does not check the status. */ |
| 46 | @Synchronized |
| 47 | fun send(method: String, path: String, body: ByteArray? = null): Response { |
| 48 | val reused = session != null |
| 49 | val s = session ?: Session(tunnel.open(443)) |
| 50 | session = null |
| 51 | val r = try { |
| 52 | exchange(s.tls.inputStream, s.tls.outputStream, method, HTTPS_HOST, "/" + path.trimStart('/'), body, keepAlive = true) |
| 53 | } catch (e: IOException) { |
| 54 | s.close() |
| 55 | // The car may have closed the idle connection. The retry opens a fresh one. |
| 56 | if (reused) return send(method, path, body) |
| 57 | throw e |
| 58 | } |
| 59 | if (r.reusable) session = s else s.close() |
| 60 | if (BuildConfig.DEBUG) Log.d("Http", "$method $path -> ${r.status} ${r.text.take(3000)}") |
| 61 | return r |
| 62 | } |
| 63 | |
| 64 | private var session: Session? = null |
| 65 | |
| 66 | private inner class Session(val channel: Tunnel.Channel) { |
| 67 | val tls = try { |
| 68 | sessionTls(channel.input, channel.output, psk.first, psk.second) |
| 69 | } catch (e: IOException) { |
| 70 | channel.close() |
| 71 | throw e |
| 72 | } |
| 73 | |
| 74 | fun close() { |
| 75 | runCatching { tls.close() } |
| 76 | channel.close() |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | private fun register(onPin: (String) -> Unit): String { |
| 81 | val r = plain("POST", "/auth/registration", ByteArray(0)) |
| 82 | if (r.status != 303) throw IOException("Registration failed: expected HTTP 303, got ${r.status}") |
| 83 | val location = r.headers["content-location"] ?: throw IOException("Registration failed: no content-location header") |
| 84 | return tunnel.open(443, REGISTRATION_TIMEOUT_MS).use { ch -> |
| 85 | val (tls, pin) = registrationTls(ch.input, ch.output) |
| 86 | onPin(pin) |
| 87 | tls.exchangeAndClose("GET", location, null) |
| 88 | }.ok().text |
| 89 | } |
| 90 | |
| 91 | private fun plain(method: String, path: String, body: ByteArray? = null) = |
| 92 | tunnel.open(80).use { exchange(it.input, it.output, method, HTTP_HOST, path, body) } |
| 93 | |
| 94 | private fun TlsClientProtocol.exchangeAndClose(method: String, path: String, body: ByteArray?) = |
| 95 | try { |
| 96 | exchange(inputStream, outputStream, method, HTTPS_HOST, path, body) |
| 97 | } finally { |
| 98 | runCatching { close() } |
| 99 | } |
| 100 | |
| 101 | internal fun Response.ok() = apply { |
| 102 | if (status !in 200..299) throw IOException("HTTP $status: ${text.take(200)}") |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | internal fun parseCredentials(s: String): Pair<String, String> { |
| 107 | val parts = s.trim().split(',') |
| 108 | if (parts.size != 2) throw IOException("Invalid credentials from the car") |
| 109 | return parts[0] to parts[1] |
| 110 | } |
| 111 |