MainActivity.kt
⎇
Raw
1package de.upapp
2
3import android.Manifest
4import android.annotation.SuppressLint
5import android.app.Application
6import android.bluetooth.BluetoothDevice
7import android.bluetooth.BluetoothManager
8import android.bluetooth.BluetoothSocket
9import android.content.Context
10import android.content.pm.PackageManager
11import android.os.Bundle
12import android.util.Log
13import androidx.activity.ComponentActivity
14import androidx.activity.compose.rememberLauncherForActivityResult
15import androidx.activity.compose.setContent
16import androidx.activity.enableEdgeToEdge
17import androidx.activity.result.contract.ActivityResultContracts
18import androidx.annotation.StringRes
19import androidx.compose.foundation.clickable
20import androidx.compose.foundation.isSystemInDarkTheme
21import androidx.compose.foundation.layout.Arrangement
22import androidx.compose.foundation.layout.Box
23import androidx.compose.foundation.layout.Column
24import androidx.compose.foundation.layout.Row
25import androidx.compose.foundation.layout.fillMaxSize
26import androidx.compose.foundation.layout.fillMaxWidth
27import androidx.compose.foundation.layout.padding
28import androidx.compose.foundation.rememberScrollState
29import androidx.compose.foundation.verticalScroll
30import androidx.compose.material.icons.Icons
31import androidx.compose.material.icons.filled.Build
32import androidx.compose.material.icons.filled.Close
33import androidx.compose.material.icons.filled.DateRange
34import androidx.compose.material.icons.filled.Home
35import androidx.compose.material.icons.filled.Refresh
36import androidx.compose.material3.Button
37import androidx.compose.material3.CircularProgressIndicator
38import androidx.compose.material3.ExperimentalMaterial3Api
39import androidx.compose.material3.HorizontalDivider
40import androidx.compose.material3.Icon
41import androidx.compose.material3.IconButton
42import androidx.compose.material3.LinearProgressIndicator
43import androidx.compose.material3.ListItem
44import androidx.compose.material3.MaterialTheme
45import androidx.compose.material3.NavigationBar
46import androidx.compose.material3.NavigationBarItem
47import androidx.compose.material3.Scaffold
48import androidx.compose.material3.SnackbarHost
49import androidx.compose.material3.SnackbarHostState
50import androidx.compose.material3.Text
51import androidx.compose.material3.TextButton
52import androidx.compose.material3.TopAppBar
53import androidx.compose.material3.dynamicDarkColorScheme
54import androidx.compose.material3.dynamicLightColorScheme
55import androidx.compose.runtime.Composable
56import androidx.compose.runtime.LaunchedEffect
57import androidx.compose.runtime.getValue
58import androidx.compose.runtime.mutableIntStateOf
59import androidx.compose.runtime.mutableStateOf
60import androidx.compose.runtime.remember
61import androidx.compose.runtime.saveable.rememberSaveable
62import androidx.compose.runtime.setValue
63import androidx.compose.ui.Alignment
64import androidx.compose.ui.Modifier
65import androidx.compose.ui.platform.LocalContext
66import androidx.compose.ui.res.stringResource
67import androidx.compose.ui.text.font.FontFamily
68import androidx.compose.ui.text.style.TextAlign
69import androidx.compose.ui.unit.dp
70import androidx.core.content.edit
71import androidx.lifecycle.AndroidViewModel
72import androidx.lifecycle.viewModelScope
73import androidx.lifecycle.viewmodel.compose.viewModel
74import kotlinx.coroutines.Dispatchers
75import kotlinx.coroutines.delay
76import kotlinx.coroutines.launch
77import org.json.JSONArray
78import org.json.JSONObject
79import java.io.IOException
80import java.util.UUID
81
82/** Turns all writes off. The car test on 2026-10-07 covered only the timer state. */
83const val WRITES_ENABLED = true
84
85class MainActivity : ComponentActivity() {
86 override fun onCreate(savedInstanceState: Bundle?) {
87 super.onCreate(savedInstanceState)
88 enableEdgeToEdge()
89 setContent {
90 val scheme = if (isSystemInDarkTheme()) dynamicDarkColorScheme(this) else dynamicLightColorScheme(this)
91 MaterialTheme(colorScheme = scheme) { App(viewModel()) }
92 }
93 }
94}
95
96class MainViewModel(app: Application) : AndroidViewModel(app) {
97 private val credentials = app.getSharedPreferences("credentials", Context.MODE_PRIVATE)
98 private var socket: BluetoothSocket? = null
99 private var tunnel: Tunnel? = null
100 /** Addresses of devices that connected as a car before. */
101 var knownCars: Set<String> = credentials.getStringSet(KNOWN_CARS, null).orEmpty()
102 private set
103
104 var deviceName by mutableStateOf("")
105 private set
106 var connecting by mutableStateOf(false)
107 private set
108 var pin by mutableStateOf<String?>(null)
109 private set
110 var busy by mutableStateOf(false)
111 private set
112 var car by mutableStateOf<Car?>(null)
113 private set
114 var snack by mutableStateOf<String?>(null)
115 var battery by mutableStateOf<Battery?>(null)
116 private set
117 var range by mutableStateOf<Range?>(null)
118 private set
119 var info by mutableStateOf<Info?>(null)
120 private set
121 var charging by mutableStateOf<ChargingManager?>(null)
122 private set
123 var status by mutableStateOf<Status?>(null)
124 private set
125 var demo by mutableStateOf(false)
126 private set
127 val writesEnabled get() = WRITES_ENABLED || demo
128 var consoleOut by mutableStateOf("")
129 private set
130
131 @SuppressLint("MissingPermission")
132 fun connect(device: BluetoothDevice) {
133 deviceName = device.name ?: device.address
134 connecting = true
135 launch {
136 val s = openRfcomm(device).also { socket = it }
137 start(Car(Tunnel(s.inputStream, s.outputStream).also { tunnel = it }, credentials))
138 knownCars = knownCars + device.address
139 credentials.edit { putStringSet(KNOWN_CARS, knownCars) }
140 }
141 }
142
143 fun connectDemo() {
144 deviceName = str(R.string.demo_car)
145 demo = true
146 connecting = true
147 launch {
148 val prefs = getApplication<Application>().getSharedPreferences("demo", Context.MODE_PRIVATE)
149 prefs.edit { putString(DEMO_VIN, DEMO_CREDENTIALS) }
150 start(Car(DemoCar().tunnel.also { tunnel = it }, prefs))
151 }
152 }
153
154 private fun start(c: Car) {
155 c.connect { pin = it }
156 if (pin != null) snack = str(R.string.registered, c.vin)
157 car = c
158 load(c)
159 viewModelScope.launch(Dispatchers.IO) {
160 while (car === c) {
161 try {
162 val inputs = inputs(c)
163 c.listen(EVENT_PATHS + inputs.keys) { path, data ->
164 val input = inputs[path]
165 if (input != null) onInput(input, data) else viewModelScope.launch(Dispatchers.IO) { reload(c, path) }
166 }
167 } catch (e: Exception) {
168 if (car === c) Log.w("upApp", "Event connection ended", e)
169 }
170 delay(5_000)
171 }
172 }
173 }
174
175 private fun update(c: Car, path: String) {
176 when (path) {
177 "/car/batteries/" -> battery = c.battery()
178 "/car/ranges/" -> range = c.range()
179 in STATUS_PATHS -> status = c.status()
180 else -> charging = c.chargingManager()
181 }
182 }
183
184 private fun reload(c: Car, path: String) = try {
185 update(c, path)
186 } catch (e: Exception) {
187 Log.w("upApp", "Reload of $path failed", e)
188 }
189
190 fun refresh() {
191 val c = car ?: return
192 launch { load(c) }
193 }
194
195 fun save(write: Car.() -> Unit) {
196 val c = car ?: return
197 launch {
198 c.write()
199 charging = c.chargingManager()
200 snack = str(R.string.saved)
201 }
202 }
203
204 fun console(method: String, path: String, body: String?) {
205 val c = car ?: return
206 launch {
207 val r = c.send(method, path, body?.let { JSONObject(it).toString().encodeToByteArray() })
208 consoleOut = "HTTP ${r.status}\n${pretty(r.text)}"
209 }
210 }
211
212 fun disconnect() {
213 car = null
214 runCatching { socket?.close() }
215 tunnel?.close()
216 socket = null
217 tunnel = null
218 demo = false
219 status = null
220 battery = null
221 range = null
222 info = null
223 charging = null
224 consoleOut = ""
225 }
226
227 override fun onCleared() = disconnect()
228
229 /** Loads every group. A failing group does not stop the others. Throws the first error at the end. */
230 private fun load(c: Car) {
231 info = c.info()
232 listOf("/car/batteries/", "/car/ranges/", STATUS_PATHS[0], "/chargingmanager/profiles/")
233 .mapNotNull { runCatching { update(c, it) }.exceptionOrNull() }
234 .firstOrNull()?.let { throw it }
235 }
236
237 private fun launch(block: () -> Unit) = viewModelScope.launch(Dispatchers.IO) {
238 busy = true
239 try {
240 block()
241 } catch (e: Exception) {
242 Log.e("upApp", "Request failed", e)
243 snack = str(R.string.error, e.message ?: e.javaClass.simpleName)
244 if (car == null) disconnect()
245 } finally {
246 busy = false
247 connecting = false
248 pin = null
249 }
250 }
251
252 private fun str(@StringRes id: Int, vararg args: Any) = getApplication<Application>().getString(id, *args)
253}
254
255private const val KNOWN_CARS = "known cars"
256private val TUNNEL_SERVICE = UUID.fromString("3728ED40-E416-11E4-80A0-0002A5D5C51B")
257
258// Same attempts and back-off as maps+more. The first connect after pairing often fails.
259@SuppressLint("MissingPermission")
260private fun openRfcomm(device: BluetoothDevice): BluetoothSocket {
261 for (attempt in 1..4) {
262 val socket = device.createRfcommSocketToServiceRecord(TUNNEL_SERVICE)
263 try {
264 socket.connect()
265 return socket
266 } catch (e: IOException) {
267 socket.close()
268 if (attempt == 4) throw e
269 Log.w("upApp", "Bluetooth connect attempt $attempt failed", e)
270 Thread.sleep(2_000L * attempt)
271 }
272 }
273 error("unreachable")
274}
275
276private fun pretty(text: String) =
277 runCatching { JSONObject(text).toString(2) }.recoverCatching { JSONArray(text).toString(2) }.getOrDefault(text)
278
279private val STATUS_PATHS = listOf(
280 "/chargingmanager/batteryCharges/", "/chargingmanager/batteryPlugs/", "/chargingmanager/batteryClimates/", "/car/environments/",
281)
282private val EVENT_PATHS = listOf(
283 "/car/batteries/", "/car/ranges/", "/chargingmanager/timers/", "/chargingmanager/profiles/", "/chargingmanager/providers/",
284) + STATUS_PATHS
285
286/** Infotainment keys and knobs by element URI. Each one needs its own subscription, like in maps+more. */
287private fun inputs(c: Car): Map<String, String> = listOf("/mechanicalinput/buttons/", "/mechanicalinput/rotarypushes/").flatMap { path ->
288 runCatching {
289 val items = JSONObject(c.get(path)).getJSONArray("data")
290 List(items.length()) { items.getJSONObject(it) }.map { it.getString("uri") to it.optString("meaning").ifEmpty { it.optString("name") } }
291 }.getOrDefault(emptyList())
292}.toMap()
293
294// TODO: only logged for now. maps+more opens the screen that matches the pressed key.
295private fun onInput(name: String, data: Any?) {
296 val o = (data as? JSONObject) ?: (data as? org.json.JSONArray)?.optJSONObject(0)
297 Log.i("Input", "$name: state=${o?.optString("state")} ticks=${o?.opt("ticks")}")
298}
299
300private val TABS = listOf(R.string.tab_overview to Icons.Default.Home, R.string.tab_charging to Icons.Default.DateRange, R.string.tab_console to Icons.Default.Build)
301
302@OptIn(ExperimentalMaterial3Api::class)
303@Composable
304fun App(vm: MainViewModel) {
305 val snackbar = remember { SnackbarHostState() }
306 LaunchedEffect(vm.snack) {
307 vm.snack?.let {
308 snackbar.showSnackbar(it)
309 vm.snack = null
310 }
311 }
312 var tab by rememberSaveable { mutableIntStateOf(0) }
313 val car = vm.car
314 Scaffold(
315 topBar = {
316 if (car != null) TopAppBar(
317 title = {
318 Column {
319 Text(vm.deviceName)
320 Text(stringResource(R.string.connected_vin, car.vin), style = MaterialTheme.typography.bodySmall)
321 }
322 },
323 actions = {
324 IconButton(onClick = vm::refresh, enabled = !vm.busy) { Icon(Icons.Default.Refresh, stringResource(R.string.refresh)) }
325 IconButton(onClick = vm::disconnect) { Icon(Icons.Default.Close, stringResource(R.string.disconnect)) }
326 },
327 )
328 },
329 bottomBar = {
330 if (car != null) NavigationBar {
331 TABS.forEachIndexed { i, (label, icon) ->
332 NavigationBarItem(selected = tab == i, onClick = { tab = i }, icon = { Icon(icon, null) }, label = { Text(stringResource(label)) })
333 }
334 }
335 },
336 snackbarHost = { SnackbarHost(snackbar) },
337 ) { padding ->
338 Box(Modifier.padding(padding).fillMaxSize()) {
339 when {
340 vm.pin != null -> PinScreen(vm.pin!!, vm::disconnect)
341 vm.connecting -> Waiting(stringResource(R.string.connecting, vm.deviceName))
342 car == null -> DevicePicker(vm.knownCars, vm::connect, vm::connectDemo)
343 tab == 0 -> Overview(vm)
344 tab == 1 -> Charging(vm)
345 else -> Console(vm)
346 }
347 if (vm.busy && car != null) LinearProgressIndicator(Modifier.fillMaxWidth())
348 }
349 }
350}
351
352@SuppressLint("MissingPermission")
353@Composable
354private fun DevicePicker(known: Set<String>, onPick: (BluetoothDevice) -> Unit, onDemo: () -> Unit) {
355 val ctx = LocalContext.current
356 var granted by remember { mutableStateOf(ctx.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) }
357 val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted = it }
358 Column(Modifier.verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
359 Text(stringResource(R.string.select_car), style = MaterialTheme.typography.headlineSmall)
360 if (!granted) {
361 Text(stringResource(R.string.bt_permission))
362 Button(onClick = { launcher.launch(Manifest.permission.BLUETOOTH_CONNECT) }) { Text(stringResource(R.string.allow_bluetooth)) }
363 } else {
364 Text(stringResource(R.string.select_car_hint), color = MaterialTheme.colorScheme.onSurfaceVariant)
365 val devices = remember {
366 ctx.getSystemService(BluetoothManager::class.java).adapter?.bondedDevices.orEmpty().sortedBy { it.name }
367 }
368 if (devices.isEmpty()) Text(stringResource(R.string.no_devices))
369 // A car is a device that connected before, or one that offers the tunnel service.
370 val (cars, others) = devices.partition { d -> d.address in known || d.uuids.orEmpty().any { it.uuid == TUNNEL_SERVICE } }
371 listOf(R.string.your_cars to cars, R.string.other_devices to others).filter { it.second.isNotEmpty() }.forEach { (title, list) ->
372 Text(stringResource(title), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
373 list.forEach { d ->
374 ListItem(
375 headlineContent = { Text(d.name ?: d.address) },
376 supportingContent = { Text(d.address, fontFamily = FontFamily.Monospace) },
377 modifier = Modifier.clickable { onPick(d) },
378 )
379 }
380 }
381 }
382 HorizontalDivider()
383 ListItem(
384 headlineContent = { Text(stringResource(R.string.demo_car)) },
385 supportingContent = { Text(stringResource(R.string.demo_car_hint)) },
386 modifier = Modifier.clickable(onClick = onDemo),
387 )
388 }
389}
390
391@Composable
392internal fun Waiting(text: String?) = Column(
393 Modifier.fillMaxSize().padding(24.dp),
394 verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
395 horizontalAlignment = Alignment.CenterHorizontally,
396) {
397 CircularProgressIndicator()
398 text?.let { Text(it) }
399}
400
401@Composable
402private fun PinScreen(pin: String, onCancel: () -> Unit) = Column(
403 Modifier.fillMaxSize().padding(24.dp),
404 verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
405 horizontalAlignment = Alignment.CenterHorizontally,
406) {
407 Row(verticalAlignment = Alignment.CenterVertically) {
408 Text(stringResource(R.string.confirm_pin_title), style = MaterialTheme.typography.titleLarge)
409 InfoButton(Topic.PIN)
410 }
411 Text(stringResource(R.string.confirm_pin_text), textAlign = TextAlign.Center)
412 Text(pin.chunked(3).joinToString(" "), style = MaterialTheme.typography.displayLarge, fontFamily = FontFamily.Monospace)
413 Text(stringResource(R.string.pin_note), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall)
414 CircularProgressIndicator()
415 TextButton(onClick = onCancel) { Text(stringResource(R.string.cancel)) }
416}
417