Car.kt
⎇
Raw
1package de.upapp
2
3import android.content.SharedPreferences
4import android.util.Log
5import androidx.core.content.edit
6import org.bouncycastle.tls.AlertDescription
7import org.bouncycastle.tls.TlsClientProtocol
8import org.bouncycastle.tls.TlsFatalAlertReceived
9import org.json.JSONObject
10import java.io.IOException
11
12// Host headers as maps+more sends them through its local port mapping.
13private const val HTTP_HOST = "127.0.0.1:4080"
14internal const val HTTPS_HOST = "127.0.0.1:4443"
15// The car answers only after the user confirms the PIN on its display.
16private const val REGISTRATION_TIMEOUT_MS = 120_000L
17
18/** REST access to the car. [credentials] stores one `user,password` pair per VIN. */
19class 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
106internal 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