MainActivity.kt
| 1 | package de.upapp |
| 2 | |
| 3 | import android.Manifest |
| 4 | import android.annotation.SuppressLint |
| 5 | import android.app.Application |
| 6 | import android.bluetooth.BluetoothDevice |
| 7 | import android.bluetooth.BluetoothManager |
| 8 | import android.bluetooth.BluetoothSocket |
| 9 | import android.content.Context |
| 10 | import android.content.pm.PackageManager |
| 11 | import android.os.Bundle |
| 12 | import android.util.Log |
| 13 | import androidx.activity.ComponentActivity |
| 14 | import androidx.activity.compose.rememberLauncherForActivityResult |
| 15 | import androidx.activity.compose.setContent |
| 16 | import androidx.activity.enableEdgeToEdge |
| 17 | import androidx.activity.result.contract.ActivityResultContracts |
| 18 | import androidx.annotation.StringRes |
| 19 | import androidx.compose.foundation.clickable |
| 20 | import androidx.compose.foundation.isSystemInDarkTheme |
| 21 | import androidx.compose.foundation.layout.Arrangement |
| 22 | import androidx.compose.foundation.layout.Box |
| 23 | import androidx.compose.foundation.layout.Column |
| 24 | import androidx.compose.foundation.layout.Row |
| 25 | import androidx.compose.foundation.layout.fillMaxSize |
| 26 | import androidx.compose.foundation.layout.fillMaxWidth |
| 27 | import androidx.compose.foundation.layout.padding |
| 28 | import androidx.compose.foundation.rememberScrollState |
| 29 | import androidx.compose.foundation.verticalScroll |
| 30 | import androidx.compose.material.icons.Icons |
| 31 | import androidx.compose.material.icons.filled.Build |
| 32 | import androidx.compose.material.icons.filled.Close |
| 33 | import androidx.compose.material.icons.filled.DateRange |
| 34 | import androidx.compose.material.icons.filled.Home |
| 35 | import androidx.compose.material.icons.filled.Refresh |
| 36 | import androidx.compose.material3.Button |
| 37 | import androidx.compose.material3.CircularProgressIndicator |
| 38 | import androidx.compose.material3.ExperimentalMaterial3Api |
| 39 | import androidx.compose.material3.HorizontalDivider |
| 40 | import androidx.compose.material3.Icon |
| 41 | import androidx.compose.material3.IconButton |
| 42 | import androidx.compose.material3.LinearProgressIndicator |
| 43 | import androidx.compose.material3.ListItem |
| 44 | import androidx.compose.material3.MaterialTheme |
| 45 | import androidx.compose.material3.NavigationBar |
| 46 | import androidx.compose.material3.NavigationBarItem |
| 47 | import androidx.compose.material3.Scaffold |
| 48 | import androidx.compose.material3.SnackbarHost |
| 49 | import androidx.compose.material3.SnackbarHostState |
| 50 | import androidx.compose.material3.Text |
| 51 | import androidx.compose.material3.TextButton |
| 52 | import androidx.compose.material3.TopAppBar |
| 53 | import androidx.compose.material3.dynamicDarkColorScheme |
| 54 | import androidx.compose.material3.dynamicLightColorScheme |
| 55 | import androidx.compose.runtime.Composable |
| 56 | import androidx.compose.runtime.LaunchedEffect |
| 57 | import androidx.compose.runtime.getValue |
| 58 | import androidx.compose.runtime.mutableIntStateOf |
| 59 | import androidx.compose.runtime.mutableStateOf |
| 60 | import androidx.compose.runtime.remember |
| 61 | import androidx.compose.runtime.saveable.rememberSaveable |
| 62 | import androidx.compose.runtime.setValue |
| 63 | import androidx.compose.ui.Alignment |
| 64 | import androidx.compose.ui.Modifier |
| 65 | import androidx.compose.ui.platform.LocalContext |
| 66 | import androidx.compose.ui.res.stringResource |
| 67 | import androidx.compose.ui.text.font.FontFamily |
| 68 | import androidx.compose.ui.text.style.TextAlign |
| 69 | import androidx.compose.ui.unit.dp |
| 70 | import androidx.core.content.edit |
| 71 | import androidx.lifecycle.AndroidViewModel |
| 72 | import androidx.lifecycle.viewModelScope |
| 73 | import androidx.lifecycle.viewmodel.compose.viewModel |
| 74 | import kotlinx.coroutines.Dispatchers |
| 75 | import kotlinx.coroutines.delay |
| 76 | import kotlinx.coroutines.launch |
| 77 | import org.json.JSONArray |
| 78 | import org.json.JSONObject |
| 79 | import java.io.IOException |
| 80 | import java.util.UUID |
| 81 | |
| 82 | /** Turns all writes off. The car test on 2026-10-07 covered only the timer state. */ |
| 83 | const val WRITES_ENABLED = true |
| 84 | |
| 85 | class 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 | |
| 96 | class 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 | |
| 255 | private const val KNOWN_CARS = "known cars" |
| 256 | private 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") |
| 260 | private 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 | |
| 276 | private fun pretty(text: String) = |
| 277 | runCatching { JSONObject(text).toString(2) }.recoverCatching { JSONArray(text).toString(2) }.getOrDefault(text) |
| 278 | |
| 279 | private val STATUS_PATHS = listOf( |
| 280 | "/chargingmanager/batteryCharges/", "/chargingmanager/batteryPlugs/", "/chargingmanager/batteryClimates/", "/car/environments/", |
| 281 | ) |
| 282 | private 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. */ |
| 287 | private 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. |
| 295 | private 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 | |
| 300 | private 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 |
| 304 | fun 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 |
| 354 | private 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 |
| 392 | internal 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 |
| 402 | private 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 |