feat: add Bluetooth Android companion
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "cloud.molberg.picar"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cloud.molberg.picar"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
buildFeatures { compose = true }
|
||||
packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
implementation("androidx.activity:activity-compose:1.10.0")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("com.google.android.gms:play-services-location:21.3.0")
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user