package de.upapp import android.Manifest import android.annotation.SuppressLint import android.app.Application import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothSocket import android.content.Context import android.content.pm.PackageManager import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.edit import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject import java.io.IOException import java.util.UUID /** Turns all writes off. The car test on 2026-10-07 covered only the timer state. */ const val WRITES_ENABLED = true class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { val scheme = if (isSystemInDarkTheme()) dynamicDarkColorScheme(this) else dynamicLightColorScheme(this) MaterialTheme(colorScheme = scheme) { App(viewModel()) } } } } class MainViewModel(app: Application) : AndroidViewModel(app) { private val credentials = app.getSharedPreferences("credentials", Context.MODE_PRIVATE) private var socket: BluetoothSocket? = null private var tunnel: Tunnel? = null /** Addresses of devices that connected as a car before. */ var knownCars: Set = credentials.getStringSet(KNOWN_CARS, null).orEmpty() private set var deviceName by mutableStateOf("") private set var connecting by mutableStateOf(false) private set var pin by mutableStateOf(null) private set var busy by mutableStateOf(false) private set var car by mutableStateOf(null) private set var snack by mutableStateOf(null) var battery by mutableStateOf(null) private set var range by mutableStateOf(null) private set var info by mutableStateOf(null) private set var charging by mutableStateOf(null) private set var status by mutableStateOf(null) private set var demo by mutableStateOf(false) private set val writesEnabled get() = WRITES_ENABLED || demo var consoleOut by mutableStateOf("") private set @SuppressLint("MissingPermission") fun connect(device: BluetoothDevice) { deviceName = device.name ?: device.address connecting = true launch { val s = openRfcomm(device).also { socket = it } start(Car(Tunnel(s.inputStream, s.outputStream).also { tunnel = it }, credentials)) knownCars = knownCars + device.address credentials.edit { putStringSet(KNOWN_CARS, knownCars) } } } fun connectDemo() { deviceName = str(R.string.demo_car) demo = true connecting = true launch { val prefs = getApplication().getSharedPreferences("demo", Context.MODE_PRIVATE) prefs.edit { putString(DEMO_VIN, DEMO_CREDENTIALS) } start(Car(DemoCar().tunnel.also { tunnel = it }, prefs)) } } private fun start(c: Car) { c.connect { pin = it } if (pin != null) snack = str(R.string.registered, c.vin) car = c load(c) viewModelScope.launch(Dispatchers.IO) { while (car === c) { try { val inputs = inputs(c) c.listen(EVENT_PATHS + inputs.keys) { path, data -> val input = inputs[path] if (input != null) onInput(input, data) else viewModelScope.launch(Dispatchers.IO) { reload(c, path) } } } catch (e: Exception) { if (car === c) Log.w("upApp", "Event connection ended", e) } delay(5_000) } } } private fun update(c: Car, path: String) { when (path) { "/car/batteries/" -> battery = c.battery() "/car/ranges/" -> range = c.range() in STATUS_PATHS -> status = c.status() else -> charging = c.chargingManager() } } private fun reload(c: Car, path: String) = try { update(c, path) } catch (e: Exception) { Log.w("upApp", "Reload of $path failed", e) } fun refresh() { val c = car ?: return launch { load(c) } } fun save(write: Car.() -> Unit) { val c = car ?: return launch { c.write() charging = c.chargingManager() snack = str(R.string.saved) } } fun console(method: String, path: String, body: String?) { val c = car ?: return launch { val r = c.send(method, path, body?.let { JSONObject(it).toString().encodeToByteArray() }) consoleOut = "HTTP ${r.status}\n${pretty(r.text)}" } } fun disconnect() { car = null runCatching { socket?.close() } tunnel?.close() socket = null tunnel = null demo = false status = null battery = null range = null info = null charging = null consoleOut = "" } override fun onCleared() = disconnect() /** Loads every group. A failing group does not stop the others. Throws the first error at the end. */ private fun load(c: Car) { info = c.info() listOf("/car/batteries/", "/car/ranges/", STATUS_PATHS[0], "/chargingmanager/profiles/") .mapNotNull { runCatching { update(c, it) }.exceptionOrNull() } .firstOrNull()?.let { throw it } } private fun launch(block: () -> Unit) = viewModelScope.launch(Dispatchers.IO) { busy = true try { block() } catch (e: Exception) { Log.e("upApp", "Request failed", e) snack = str(R.string.error, e.message ?: e.javaClass.simpleName) if (car == null) disconnect() } finally { busy = false connecting = false pin = null } } private fun str(@StringRes id: Int, vararg args: Any) = getApplication().getString(id, *args) } private const val KNOWN_CARS = "known cars" private val TUNNEL_SERVICE = UUID.fromString("3728ED40-E416-11E4-80A0-0002A5D5C51B") // Same attempts and back-off as maps+more. The first connect after pairing often fails. @SuppressLint("MissingPermission") private fun openRfcomm(device: BluetoothDevice): BluetoothSocket { for (attempt in 1..4) { val socket = device.createRfcommSocketToServiceRecord(TUNNEL_SERVICE) try { socket.connect() return socket } catch (e: IOException) { socket.close() if (attempt == 4) throw e Log.w("upApp", "Bluetooth connect attempt $attempt failed", e) Thread.sleep(2_000L * attempt) } } error("unreachable") } private fun pretty(text: String) = runCatching { JSONObject(text).toString(2) }.recoverCatching { JSONArray(text).toString(2) }.getOrDefault(text) private val STATUS_PATHS = listOf( "/chargingmanager/batteryCharges/", "/chargingmanager/batteryPlugs/", "/chargingmanager/batteryClimates/", "/car/environments/", ) private val EVENT_PATHS = listOf( "/car/batteries/", "/car/ranges/", "/chargingmanager/timers/", "/chargingmanager/profiles/", "/chargingmanager/providers/", ) + STATUS_PATHS /** Infotainment keys and knobs by element URI. Each one needs its own subscription, like in maps+more. */ private fun inputs(c: Car): Map = listOf("/mechanicalinput/buttons/", "/mechanicalinput/rotarypushes/").flatMap { path -> runCatching { val items = JSONObject(c.get(path)).getJSONArray("data") List(items.length()) { items.getJSONObject(it) }.map { it.getString("uri") to it.optString("meaning").ifEmpty { it.optString("name") } } }.getOrDefault(emptyList()) }.toMap() // TODO: only logged for now. maps+more opens the screen that matches the pressed key. private fun onInput(name: String, data: Any?) { val o = (data as? JSONObject) ?: (data as? org.json.JSONArray)?.optJSONObject(0) Log.i("Input", "$name: state=${o?.optString("state")} ticks=${o?.opt("ticks")}") } 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) @OptIn(ExperimentalMaterial3Api::class) @Composable fun App(vm: MainViewModel) { val snackbar = remember { SnackbarHostState() } LaunchedEffect(vm.snack) { vm.snack?.let { snackbar.showSnackbar(it) vm.snack = null } } var tab by rememberSaveable { mutableIntStateOf(0) } val car = vm.car Scaffold( topBar = { if (car != null) TopAppBar( title = { Column { Text(vm.deviceName) Text(stringResource(R.string.connected_vin, car.vin), style = MaterialTheme.typography.bodySmall) } }, actions = { IconButton(onClick = vm::refresh, enabled = !vm.busy) { Icon(Icons.Default.Refresh, stringResource(R.string.refresh)) } IconButton(onClick = vm::disconnect) { Icon(Icons.Default.Close, stringResource(R.string.disconnect)) } }, ) }, bottomBar = { if (car != null) NavigationBar { TABS.forEachIndexed { i, (label, icon) -> NavigationBarItem(selected = tab == i, onClick = { tab = i }, icon = { Icon(icon, null) }, label = { Text(stringResource(label)) }) } } }, snackbarHost = { SnackbarHost(snackbar) }, ) { padding -> Box(Modifier.padding(padding).fillMaxSize()) { when { vm.pin != null -> PinScreen(vm.pin!!, vm::disconnect) vm.connecting -> Waiting(stringResource(R.string.connecting, vm.deviceName)) car == null -> DevicePicker(vm.knownCars, vm::connect, vm::connectDemo) tab == 0 -> Overview(vm) tab == 1 -> Charging(vm) else -> Console(vm) } if (vm.busy && car != null) LinearProgressIndicator(Modifier.fillMaxWidth()) } } } @SuppressLint("MissingPermission") @Composable private fun DevicePicker(known: Set, onPick: (BluetoothDevice) -> Unit, onDemo: () -> Unit) { val ctx = LocalContext.current var granted by remember { mutableStateOf(ctx.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted = it } Column(Modifier.verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(stringResource(R.string.select_car), style = MaterialTheme.typography.headlineSmall) if (!granted) { Text(stringResource(R.string.bt_permission)) Button(onClick = { launcher.launch(Manifest.permission.BLUETOOTH_CONNECT) }) { Text(stringResource(R.string.allow_bluetooth)) } } else { Text(stringResource(R.string.select_car_hint), color = MaterialTheme.colorScheme.onSurfaceVariant) val devices = remember { ctx.getSystemService(BluetoothManager::class.java).adapter?.bondedDevices.orEmpty().sortedBy { it.name } } if (devices.isEmpty()) Text(stringResource(R.string.no_devices)) // A car is a device that connected before, or one that offers the tunnel service. val (cars, others) = devices.partition { d -> d.address in known || d.uuids.orEmpty().any { it.uuid == TUNNEL_SERVICE } } listOf(R.string.your_cars to cars, R.string.other_devices to others).filter { it.second.isNotEmpty() }.forEach { (title, list) -> Text(stringResource(title), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) list.forEach { d -> ListItem( headlineContent = { Text(d.name ?: d.address) }, supportingContent = { Text(d.address, fontFamily = FontFamily.Monospace) }, modifier = Modifier.clickable { onPick(d) }, ) } } } HorizontalDivider() ListItem( headlineContent = { Text(stringResource(R.string.demo_car)) }, supportingContent = { Text(stringResource(R.string.demo_car_hint)) }, modifier = Modifier.clickable(onClick = onDemo), ) } } @Composable internal fun Waiting(text: String?) = Column( Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { CircularProgressIndicator() text?.let { Text(it) } } @Composable private fun PinScreen(pin: String, onCancel: () -> Unit) = Column( Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { Row(verticalAlignment = Alignment.CenterVertically) { Text(stringResource(R.string.confirm_pin_title), style = MaterialTheme.typography.titleLarge) InfoButton(Topic.PIN) } Text(stringResource(R.string.confirm_pin_text), textAlign = TextAlign.Center) Text(pin.chunked(3).joinToString(" "), style = MaterialTheme.typography.displayLarge, fontFamily = FontFamily.Monospace) Text(stringResource(R.string.pin_note), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall) CircularProgressIndicator() TextButton(onClick = onCancel) { Text(stringResource(R.string.cancel)) } }