feat: add Bluetooth Android companion

This commit is contained in:
2026-07-31 23:32:57 +02:00
parent e53b7400bd
commit 2d715abb12
35 changed files with 1739 additions and 4 deletions
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<application
android:name=".CompanionApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="Pi Car Companion"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.PiCar">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".CompanionService"
android:exported="false"
android:foregroundServiceType="location|connectedDevice" />
</application>
</manifest>
@@ -0,0 +1,125 @@
package cloud.molberg.picar
import android.annotation.SuppressLint
import android.bluetooth.*
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.ParcelUuid
import android.os.Handler
import android.os.Looper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@SuppressLint("MissingPermission")
class BleClient(private val context: Context) {
companion object {
val SERVICE_UUID: UUID = UUID.fromString("6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001")
val RX_UUID: UUID = UUID.fromString("6c7a0002-7c6d-4f74-9bb0-c5a4e5efc001")
val TX_UUID: UUID = UUID.fromString("6c7a0003-7c6d-4f74-9bb0-c5a4e5efc001")
val CCC_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
val state = MutableStateFlow(ConnectionState.STOPPED)
private val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
private var gatt: BluetoothGatt? = null
private var rx: BluetoothGattCharacteristic? = null
private val incoming = StringBuilder()
private val pending = ConcurrentHashMap<String, CompletableDeferred<JSONObject>>()
private val writes = ArrayDeque<ByteArray>()
private val handler = Handler(Looper.getMainLooper())
private var stopped = true
fun connect() {
stopped = false
val adapter = bluetoothManager.adapter ?: return
state.value = ConnectionState.SCANNING
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
adapter.bluetoothLeScanner?.startScan(listOf(filter), ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scanCallback)
}
fun disconnect() {
stopped = true
handler.removeCallbacksAndMessages(null)
bluetoothManager.adapter?.bluetoothLeScanner?.stopScan(scanCallback)
gatt?.close(); gatt = null; rx = null
state.value = ConnectionState.STOPPED
}
suspend fun request(operation: String, token: String?, payload: JSONObject = JSONObject()): JSONObject {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<JSONObject>()
pending[id] = deferred
val message = JSONObject().put("id", id).put("op", operation).put("payload", payload)
if (token != null) message.put("token", token)
enqueue((message.toString() + "\n").toByteArray())
return try {
withTimeout(20_000) { deferred.await() }
} finally {
pending.remove(id)
}
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
bluetoothManager.adapter.bluetoothLeScanner?.stopScan(this)
state.value = ConnectionState.CONNECTING
gatt = result.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
}
override fun onScanFailed(errorCode: Int) { state.value = ConnectionState.DISCONNECTED }
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) { this@BleClient.gatt = gatt; gatt.requestMtu(247); gatt.discoverServices() }
else {
rx = null; gatt.close(); state.value = ConnectionState.DISCONNECTED
if (!stopped) handler.postDelayed({ connect() }, 3_000)
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val service = gatt.getService(SERVICE_UUID) ?: return
rx = service.getCharacteristic(RX_UUID)
val tx = service.getCharacteristic(TX_UUID) ?: return
gatt.setCharacteristicNotification(tx, true)
tx.getDescriptor(CCC_UUID)?.let { descriptor ->
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
state.value = ConnectionState.CONNECTED
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
incoming.append(characteristic.value.toString(Charsets.UTF_8))
while (incoming.contains("\n")) {
val index = incoming.indexOf("\n")
val line = incoming.substring(0, index); incoming.delete(0, index + 1)
runCatching { JSONObject(line) }.getOrNull()?.let { response ->
pending.remove(response.optString("id"))?.let { deferred ->
if (response.optBoolean("ok")) deferred.complete(response.getJSONObject("data"))
else deferred.completeExceptionally(IllegalStateException(response.optJSONObject("error")?.optString("message") ?: "Bluetooth request failed"))
}
}
}
}
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { writeNext() }
}
@Synchronized private fun enqueue(bytes: ByteArray) {
bytes.asList().chunked(180).forEach { chunk -> writes.add(chunk.toByteArray()) }
if (writes.size == bytes.asList().chunked(180).size) writeNext()
}
@Synchronized private fun writeNext() {
val characteristic = rx ?: return
val next = writes.removeFirstOrNull() ?: return
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = next
gatt?.writeCharacteristic(characteristic)
}
}
@@ -0,0 +1,7 @@
package cloud.molberg.picar
import android.app.Application
class CompanionApplication : Application() {
val repository by lazy { CompanionRepository(this) }
}
@@ -0,0 +1,156 @@
package cloud.molberg.picar
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.os.BatteryManager
import android.provider.Settings
import com.google.android.gms.location.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.nio.charset.StandardCharsets
import java.util.UUID
class CompanionRepository(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val tokenStore = TokenStore(context)
private val ble = BleClient(context)
private val locationClient = LocationServices.getFusedLocationProviderClient(context)
private val mutableState = MutableStateFlow(CompanionUiState(paired = tokenStore.load() != null))
val state = mutableState.asStateFlow()
private var polling: Job? = null
init {
scope.launch { ble.state.collect { connection ->
mutableState.value = mutableState.value.copy(connection = connection)
if (connection == ConnectionState.CONNECTED) startPolling()
} }
}
fun connect() = ble.connect()
fun stop() { polling?.cancel(); ble.disconnect() }
fun pair(code: String, onComplete: (Boolean) -> Unit) {
scope.launch {
runCatching {
val deviceId = stableDeviceId()
val payload = JSONObject().put("code", code).put("deviceId", deviceId).put("deviceName", android.os.Build.MODEL.take(80))
val response = ble.request("pair", null, payload)
tokenStore.save(response.getString("token"))
mutableState.value = mutableState.value.copy(paired = true, error = null)
}.onFailure { mutableState.value = mutableState.value.copy(error = it.message) }
withContext(Dispatchers.Main) { onComplete(tokenStore.load() != null) }
}
}
fun forget() {
tokenStore.clear()
mutableState.value = mutableState.value.copy(paired = false, snapshot = null)
}
fun refresh() = scope.launch { fetchSnapshot() }
fun launchShortcut(shortcut: Shortcut) {
val token = tokenStore.load() ?: return
scope.launch {
mutableState.value = mutableState.value.copy(busyShortcut = shortcut.id, error = null)
runCatching { ble.request("launchShortcut", token, JSONObject().put("id", shortcut.id)) }
.onFailure { mutableState.value = mutableState.value.copy(error = it.message) }
mutableState.value = mutableState.value.copy(busyShortcut = null)
}
}
private fun startPolling() {
polling?.cancel()
polling = scope.launch {
while (isActive) { fetchSnapshot(); delay(5_000) }
}
}
private suspend fun fetchSnapshot() {
val token = tokenStore.load() ?: return
runCatching { parseSnapshot(ble.request("snapshot", token)) }
.onSuccess { mutableState.value = mutableState.value.copy(snapshot = it, error = null) }
.onFailure { mutableState.value = mutableState.value.copy(error = it.message) }
}
@SuppressLint("MissingPermission")
fun startLocation() {
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 2_000)
.setMinUpdateIntervalMillis(1_000).setMinUpdateDistanceMeters(3f).build()
locationClient.requestLocationUpdates(request, locationCallback, context.mainLooper)
}
fun stopLocation() = locationClient.removeLocationUpdates(locationCallback)
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) { result.lastLocation?.let(::sendLocation) }
}
private fun sendLocation(location: Location) {
val token = tokenStore.load() ?: return
if (ble.state.value != ConnectionState.CONNECTED) return
val battery = context.getSystemService(BatteryManager::class.java).getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val payload = JSONObject()
.put("capturedAt", java.time.Instant.ofEpochMilli(location.time).toString())
.put("latitude", location.latitude).put("longitude", location.longitude)
.put("accuracyMeters", location.accuracy.toDouble())
.put("altitudeMeters", if (location.hasAltitude()) location.altitude else JSONObject.NULL)
.put("speedKph", if (location.hasSpeed()) location.speed * 3.6 else JSONObject.NULL)
.put("bearingDegrees", if (location.hasBearing()) location.bearing.toDouble() else JSONObject.NULL)
.put("batteryPercent", if (battery in 0..100) battery else JSONObject.NULL)
.put("networkType", networkType())
scope.launch { runCatching { ble.request("location", token, payload) } }
}
private fun networkType(): String {
val manager = context.getSystemService(android.net.ConnectivityManager::class.java)
val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return "offline"
return when {
capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
else -> "other"
}
}
private fun stableDeviceId(): String {
val source = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: android.os.Build.MODEL
return UUID.nameUUIDFromBytes(source.toByteArray(StandardCharsets.UTF_8)).toString()
}
private fun parseSnapshot(json: JSONObject): Snapshot {
val system = json.getJSONObject("system")
val memory = system.getJSONObject("memory")
val disk = system.getJSONObject("disk")
val diskValue = if (disk.optBoolean("available")) disk.optJSONObject("value") else null
val car = json.optJSONObject("car")
val stats = json.getJSONObject("statistics")
val lifetime = stats.getJSONObject("lifetime")
val records = stats.getJSONObject("records")
fun availableNumber(name: String) = car?.optJSONObject(name)?.takeIf { it.optBoolean("available") }?.optDouble("value")
fun availableString(name: String) = car?.optJSONObject(name)?.takeIf { it.optBoolean("available") }?.optString("value")
val shortcutsJson = json.getJSONArray("shortcuts")
val shortcuts = (0 until shortcutsJson.length()).map { index -> shortcutsJson.getJSONObject(index).let { Shortcut(it.getInt("id"), it.getString("name"), it.getString("value")) } }
return Snapshot(
json.getString("collectedAt"),
SystemSummary(
system.getString("hostname"),
system.getJSONObject("cpu").getJSONObject("temperatureCelsius").takeIf { it.optBoolean("available") }?.optDouble("value"),
((memory.getDouble("usedBytes") / memory.getDouble("totalBytes")) * 100).toInt(),
diskValue?.let { ((it.getDouble("usedBytes") / it.getDouble("totalBytes")) * 100).toInt() },
system.getDouble("uptimeSeconds").toLong(),
system.getJSONObject("headUnit").optBoolean("available")
),
CarSummary(availableNumber("speedKph"), availableString("gear"), availableNumber("wheelAngleDegrees"),
availableNumber("batteryPercent"), availableNumber("rangeKm"), availableNumber("batteryVoltage"),
availableNumber("totalConsumptionKwh"), lifetime.optDouble("trackedDistanceKm", 0.0),
lifetime.optNullableDouble("averageMovingSpeedKph"), records.optNullableDouble("peakSpeedKph")),
shortcuts
)
}
private fun JSONObject.optNullableDouble(name: String): Double? = if (isNull(name) || !has(name)) null else getDouble(name)
}
@@ -0,0 +1,29 @@
package cloud.molberg.picar
import android.app.*
import android.content.Intent
import android.os.IBinder
class CompanionService : Service() {
private val repository get() = (application as CompanionApplication).repository
override fun onCreate() {
super.onCreate()
val channel = NotificationChannel("companion_connection", "Car companion connection", NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val launch = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
val notification = Notification.Builder(this, channel.id)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentTitle("Pi Car Companion")
.setContentText("Bluetooth and phone GPS are active")
.setContentIntent(launch)
.setOngoing(true)
.build()
startForeground(42, notification)
repository.connect()
repository.startLocation()
}
override fun onDestroy() { repository.stopLocation(); repository.stop(); super.onDestroy() }
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,126 @@
package cloud.molberg.picar
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import java.util.Locale
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val repository = (application as CompanionApplication).repository
setContent { CompanionTheme { CompanionApp(repository) { startCompanionService() } } }
}
private fun startCompanionService() = ContextCompat.startForegroundService(this, Intent(this, CompanionService::class.java))
}
@Composable private fun CompanionApp(repository: CompanionRepository, startService: () -> Unit) {
val state by repository.state.collectAsStateWithLifecycle()
val permissions = buildList {
if (Build.VERSION.SDK_INT >= 31) { add(Manifest.permission.BLUETOOTH_SCAN); add(Manifest.permission.BLUETOOTH_CONNECT) }
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS)
}.toTypedArray()
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> if (grants.values.all { it }) startService() }
LaunchedEffect(Unit) { launcher.launch(permissions) }
if (!state.paired) PairScreen(state, onConnect = startService, onPair = repository::pair)
else Dashboard(state, repository::refresh, repository::launchShortcut, repository::forget)
}
@Composable private fun PairScreen(state: CompanionUiState, onConnect: () -> Unit, onPair: (String, (Boolean) -> Unit) -> Unit) {
var code by remember { mutableStateOf("") }
Surface(Modifier.fillMaxSize(), color = Bg) {
Column(Modifier.padding(28.dp), verticalArrangement = Arrangement.Center) {
Text("PI CAR COMPANION", color = Accent, fontSize = 13.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp)
Spacer(Modifier.height(14.dp)); Text("Connect your phone.", color = Text, fontSize = 38.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp)); Text("Generate a pairing code in the Pi web dashboard, then enter it here. Bluetooth and GPS stay active while you drive.", color = Muted, lineHeight = 23.sp)
Spacer(Modifier.height(28.dp))
OutlinedTextField(code, { code = it.filter(Char::isDigit).take(6) }, label = { Text("Six-digit code") }, singleLine = true, modifier = Modifier.fillMaxWidth(), colors = fieldColors())
Spacer(Modifier.height(14.dp))
Button(onClick = { onConnect(); onPair(code) {} }, enabled = code.length == 6 && state.connection == ConnectionState.CONNECTED, modifier = Modifier.fillMaxWidth().height(56.dp), colors = ButtonDefaults.buttonColors(containerColor = Accent, contentColor = AccentInk)) { Text(if (state.connection == ConnectionState.CONNECTED) "Pair phone" else connectionLabel(state.connection), fontWeight = FontWeight.Bold) }
state.error?.let { Spacer(Modifier.height(12.dp)); Text(it, color = Danger) }
}
}
}
@Composable private fun Dashboard(state: CompanionUiState, refresh: () -> Unit, launch: (Shortcut) -> Unit, forget: () -> Unit) {
var tab by remember { mutableIntStateOf(0) }
Scaffold(containerColor = Bg, bottomBar = {
NavigationBar(containerColor = Surface) {
listOf("Overview", "Car", "Controls").forEachIndexed { index, title -> NavigationBarItem(selected = tab == index, onClick = { tab = index }, icon = { Text(listOf("", "", "")[index]) }, label = { Text(title) }, colors = NavigationBarItemDefaults.colors(selectedIconColor = Accent, selectedTextColor = Text, indicatorColor = Raised, unselectedIconColor = Muted, unselectedTextColor = Muted)) }
}
}) { padding ->
Column(Modifier.padding(padding).padding(horizontal = 18.dp).fillMaxSize()) {
Row(Modifier.padding(top = 20.dp, bottom = 14.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Text("PI CAR COMPANION", color = Accent, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp); Text(state.snapshot?.system?.hostname ?: "Your Raspberry Pi", color = Text, fontSize = 25.sp, fontWeight = FontWeight.Bold) }
Text(if (state.connection == ConnectionState.CONNECTED) "● LIVE" else "● OFFLINE", color = if (state.connection == ConnectionState.CONNECTED) Accent else Danger, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
state.error?.let { Text(it, color = Danger, modifier = Modifier.padding(bottom = 8.dp)) }
when (tab) {
0 -> OverviewScreen(state.snapshot, refresh)
1 -> CarScreen(state.snapshot?.car)
else -> ControlsScreen(state, launch, forget)
}
}
}
}
@Composable private fun OverviewScreen(snapshot: Snapshot?, refresh: () -> Unit) {
val system = snapshot?.system
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 20.dp)) {
item { MetricCard("Pi status", if (system != null) "Healthy" else "Waiting", "Last snapshot ${snapshot?.collectedAt?.substringAfter('T')?.take(8) ?: "--"}", Accent) }
item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.weight(1f)) { MetricCard("CPU temperature", system?.temperatureC?.let { "${it.round(1)}°C" } ?: "--", "Thermal sensor") }; Box(Modifier.weight(1f)) { MetricCard("Memory", system?.let { "${it.memoryUsedPercent}%" } ?: "--", "Used") } } }
item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.weight(1f)) { MetricCard("Storage", system?.diskUsedPercent?.let { "$it%" } ?: "--", "Used") }; Box(Modifier.weight(1f)) { MetricCard("ADB", if (system?.adbConnected == true) "Connected" else "Offline", "Head unit") } } }
item { Button(onClick = refresh, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = Accent, contentColor = AccentInk)) { Text("Refresh now", fontWeight = FontWeight.Bold) } }
}
}
@Composable private fun CarScreen(car: CarSummary?) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 20.dp)) {
item { Card(colors = CardDefaults.cardColors(containerColor = Surface), shape = RoundedCornerShape(18.dp), modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(car?.speedKph?.round(0) ?: "--", color = Text, fontSize = 64.sp, fontWeight = FontWeight.Bold); Text("KM/H", color = Accent, fontSize = 12.sp, letterSpacing = 2.sp); Spacer(Modifier.height(18.dp)); Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { SmallValue("GEAR", car?.gear ?: "--"); SmallValue("STEERING", car?.wheelAngle?.let { "${it.round(0)}°" } ?: "--"); SmallValue("BATTERY", car?.batteryPercent?.let { "${it.round(0)}%" } ?: "--") } } } }
item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.weight(1f)) { MetricCard("Range", car?.rangeKm?.let { "${it.round(0)} km" } ?: "--", "OEM estimate") }; Box(Modifier.weight(1f)) { MetricCard("Distance", car?.trackedDistanceKm?.let { "${it.round(1)} km" } ?: "--", "Tracked") } } }
item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.weight(1f)) { MetricCard("Average", car?.averageSpeedKph?.let { "${it.round(1)} km/h" } ?: "--", "Moving speed") }; Box(Modifier.weight(1f)) { MetricCard("Peak", car?.peakSpeedKph?.let { "${it.round(0)} km/h" } ?: "--", "All time") } } }
}
}
@Composable private fun ControlsScreen(state: CompanionUiState, launch: (Shortcut) -> Unit, forget: () -> Unit) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 20.dp)) {
item { Text("ADB SHORTCUTS", color = Accent, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp) }
val shortcuts = state.snapshot?.shortcuts.orEmpty()
if (shortcuts.isEmpty()) item { MetricCard("No shortcuts", "Add them in the web dashboard", "Only saved allowlisted targets appear here") }
items(shortcuts) { shortcut -> Button(onClick = { launch(shortcut) }, enabled = state.busyShortcut == null, modifier = Modifier.fillMaxWidth().height(62.dp), colors = ButtonDefaults.buttonColors(containerColor = Surface, contentColor = Text), shape = RoundedCornerShape(14.dp)) { Column(Modifier.weight(1f), horizontalAlignment = Alignment.Start) { Text(shortcut.name, fontWeight = FontWeight.Bold); Text(shortcut.target, color = Muted, fontSize = 11.sp, maxLines = 1) }; Text(if (state.busyShortcut == shortcut.id) "" else "OPEN", color = Accent, fontWeight = FontWeight.Bold, fontSize = 12.sp) } }
item { Spacer(Modifier.height(10.dp)); TextButton(onClick = forget, modifier = Modifier.fillMaxWidth()) { Text("Forget this Pi", color = Danger) } }
}
}
@Composable private fun MetricCard(label: String, value: String, detail: String, valueColor: Color = Text) { Card(colors = CardDefaults.cardColors(containerColor = Surface), shape = RoundedCornerShape(14.dp), modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(17.dp)) { Text(label, color = Muted, fontSize = 12.sp); Spacer(Modifier.height(10.dp)); Text(value, color = valueColor, fontSize = 21.sp, fontWeight = FontWeight.Bold); Text(detail, color = Muted, fontSize = 11.sp) } } }
@Composable private fun SmallValue(label: String, value: String) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(label, color = Muted, fontSize = 10.sp); Text(value, color = Text, fontSize = 20.sp, fontWeight = FontWeight.Bold) } }
@Composable private fun fieldColors() = OutlinedTextFieldDefaults.colors(focusedTextColor = Text, unfocusedTextColor = Text, focusedBorderColor = Accent, unfocusedBorderColor = Line, focusedLabelColor = Accent, unfocusedLabelColor = Muted, cursorColor = Accent)
private fun connectionLabel(state: ConnectionState) = when (state) { ConnectionState.SCANNING -> "Finding Pi…"; ConnectionState.CONNECTING -> "Connecting…"; ConnectionState.CONNECTED -> "Pair phone"; else -> "Start Bluetooth" }
private fun Double.round(decimals: Int) = String.format(Locale.getDefault(), "%.${decimals}f", this)
private val Bg = Color(0xFF10130F); private val Surface = Color(0xFF171B16); private val Raised = Color(0xFF252B23); private val Line = Color(0xFF30372D); private val Text = Color(0xFFF0F2EB); private val Muted = Color(0xFFAEB7A7); private val Accent = Color(0xFFB9E769); private val AccentInk = Color(0xFF172008); private val Danger = Color(0xFFFF9B8D)
@Composable private fun CompanionTheme(content: @Composable () -> Unit) { MaterialTheme(colorScheme = darkColorScheme(background = Bg, surface = Surface, primary = Accent, onPrimary = AccentInk, onBackground = Text, onSurface = Text, error = Danger), typography = Typography(), content = content) }
@@ -0,0 +1,32 @@
package cloud.molberg.picar
data class Shortcut(val id: Int, val name: String, val target: String)
data class SystemSummary(
val hostname: String,
val temperatureC: Double?,
val memoryUsedPercent: Int,
val diskUsedPercent: Int?,
val uptimeSeconds: Long,
val adbConnected: Boolean
)
data class CarSummary(
val speedKph: Double?,
val gear: String?,
val wheelAngle: Double?,
val batteryPercent: Double?,
val rangeKm: Double?,
val voltage: Double?,
val consumptionKwh: Double?,
val trackedDistanceKm: Double,
val averageSpeedKph: Double?,
val peakSpeedKph: Double?
)
data class Snapshot(val collectedAt: String, val system: SystemSummary, val car: CarSummary, val shortcuts: List<Shortcut>)
enum class ConnectionState { STOPPED, SCANNING, CONNECTING, CONNECTED, DISCONNECTED }
data class CompanionUiState(
val connection: ConnectionState = ConnectionState.STOPPED,
val paired: Boolean = false,
val snapshot: Snapshot? = null,
val error: String? = null,
val busyShortcut: Int? = null
)
@@ -0,0 +1,47 @@
package cloud.molberg.picar
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class TokenStore(context: Context) {
private val preferences = context.getSharedPreferences("companion_credentials", Context.MODE_PRIVATE)
private val alias = "pi_car_mobile_token"
fun save(token: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
preferences.edit()
.putString("token", Base64.encodeToString(cipher.doFinal(token.toByteArray()), Base64.NO_WRAP))
.putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.apply()
}
fun load(): String? = runCatching {
val encrypted = Base64.decode(preferences.getString("token", null) ?: return null, Base64.NO_WRAP)
val iv = Base64.decode(preferences.getString("iv", null) ?: return null, Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, iv))
String(cipher.doFinal(encrypted))
}.getOrNull()
fun clear() = preferences.edit().clear().apply()
private fun key(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(alias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
init(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build())
generateKey()
}
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="512" android:viewportHeight="512">
<path
android:pathData="M256,92 A164,164 0,1 0,256 420 A164,164 0,1 0,256 92"
android:fillColor="#171B16"
android:strokeColor="#30372D"
android:strokeWidth="16" />
<path
android:pathData="M148,300 A120,120 0,1 1,364 300"
android:fillColor="@android:color/transparent"
android:strokeColor="#B9E769"
android:strokeWidth="30"
android:strokeLineCap="round" />
<path
android:pathData="M256,268 L329,186"
android:fillColor="@android:color/transparent"
android:strokeColor="#F0F2EB"
android:strokeWidth="26"
android:strokeLineCap="round" />
<path
android:pathData="M256,238 A30,30 0,1 0,256 298 A30,30 0,1 0,256 238"
android:fillColor="#B9E769" />
<path
android:pathData="M173,337 L339,337"
android:fillColor="@android:color/transparent"
android:strokeColor="#F0F2EB"
android:strokeWidth="24"
android:strokeLineCap="round" />
</vector>
@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,3 @@
<resources>
<color name="launcher_background">#10130F</color>
</resources>
@@ -0,0 +1,9 @@
<resources>
<style name="Theme.PiCar" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">#10130F</item>
<item name="android:navigationBarColor">#10130F</item>
<item name="android:windowBackground">#10130F</item>
</style>
</resources>