feat: add Bluetooth Android companion
This commit is contained in:
@@ -16,6 +16,7 @@ The current MVP slice includes:
|
|||||||
- live MG4 speed, gear, and steering-angle telemetry with a dedicated visual Car dashboard;
|
- live MG4 speed, gear, and steering-angle telemetry with a dedicated visual Car dashboard;
|
||||||
- a responsive 1920×720-oriented setup, login, and dashboard UI;
|
- a responsive 1920×720-oriented setup, login, and dashboard UI;
|
||||||
- an installable PWA shell with standalone landscape display and cached dashboard assets;
|
- an installable PWA shell with standalone landscape display and cached dashboard assets;
|
||||||
|
- a focused native Android companion with authenticated BLE status, car controls, and phone GPS uplink;
|
||||||
- an idempotent Raspberry Pi installer with systemd start-on-boot support;
|
- an idempotent Raspberry Pi installer with systemd start-on-boot support;
|
||||||
- atomic, dashboard-triggered Git updates with automatic verification and rollback safety;
|
- atomic, dashboard-triggered Git updates with automatic verification and rollback safety;
|
||||||
- optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot;
|
- optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot;
|
||||||
@@ -56,6 +57,8 @@ The script asks for sudo access and then:
|
|||||||
|
|
||||||
The installer prints the final dashboard address, normally `http://<pi-hostname>.local:8787`. Open it and create the first administrator account.
|
The installer prints the final dashboard address, normally `http://<pi-hostname>.local:8787`. Open it and create the first administrator account.
|
||||||
|
|
||||||
|
It also installs the BlueZ GATT bridge used by the optional [Android companion](./android/README.md). Pair phones from **Settings → Mobile** after the Pi has Bluetooth enabled.
|
||||||
|
|
||||||
`install.sh` is idempotent. After pulling changes in the original clone, run it again to deploy that checkout:
|
`install.sh` is idempotent. After pulling changes in the original clone, run it again to deploy that checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
local.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Android companion
|
||||||
|
|
||||||
|
The Android app is a focused native companion rather than a copy of the web dashboard. It connects directly to the Pi's BLE GATT service and provides:
|
||||||
|
|
||||||
|
- live Pi health, temperature, memory, storage, and ADB connection status;
|
||||||
|
- live car speed, gear, steering, battery, range, and lifetime statistics;
|
||||||
|
- launch access to ADB shortcuts that were explicitly saved in the web dashboard;
|
||||||
|
- phone GPS, speed, bearing, accuracy, battery, and network-state uplink;
|
||||||
|
- background BLE reconnection and location updates through a foreground service.
|
||||||
|
|
||||||
|
## Pairing
|
||||||
|
|
||||||
|
1. Deploy the latest Pi release with `./install.sh` so BlueZ and the Bluetooth bridge are installed.
|
||||||
|
2. Open **Settings → Mobile** in the web dashboard and select **Pair a phone**.
|
||||||
|
3. Install and open the Android application and grant Bluetooth, notification, and precise-location permissions.
|
||||||
|
4. Enter the six-digit code shown by the dashboard.
|
||||||
|
|
||||||
|
The one-use code expires after ten minutes. The resulting credential is encrypted with Android Keystore and can be revoked from **Settings → Mobile**. A phone can only launch existing allowlisted ADB shortcuts; the Bluetooth protocol has no shell, arbitrary ADB, APK upload, update, or power operation.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Open this `android/` directory in Android Studio (JDK 17, Android SDK 35) and run the `app` configuration, or use the included Gradle wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew :app:assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
The debug APK is produced at `app/build/outputs/apk/debug/app-debug.apk`. Hardware validation requires an Android 8+ phone with BLE and a Raspberry Pi with a BlueZ-compatible BLE adapter.
|
||||||
|
|
||||||
|
## BLE protocol
|
||||||
|
|
||||||
|
The custom service UUID is `6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001`. RX and TX carry newline-delimited UTF-8 JSON, fragmented into BLE-sized chunks. Protocol version 1 exposes only `pair`, `snapshot`, `location`, and `launchShortcut` operations. Android sends GPS at most every second and refreshes the dashboard snapshot every five seconds.
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.3" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories { google(); mavenCentral(); gradlePluginPortal() }
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories { google(); mavenCentral() }
|
||||||
|
}
|
||||||
|
rootProject.name = "PiCarCompanion"
|
||||||
|
include(":app")
|
||||||
@@ -41,6 +41,7 @@ apt-get update
|
|||||||
apt-get install -y \
|
apt-get install -y \
|
||||||
android-tools-adb \
|
android-tools-adb \
|
||||||
avahi-daemon \
|
avahi-daemon \
|
||||||
|
bluez \
|
||||||
build-essential \
|
build-essential \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
conntrack \
|
conntrack \
|
||||||
@@ -53,6 +54,8 @@ apt-get install -y \
|
|||||||
network-manager \
|
network-manager \
|
||||||
nftables \
|
nftables \
|
||||||
python3 \
|
python3 \
|
||||||
|
python3-dbus \
|
||||||
|
python3-gi \
|
||||||
rsync \
|
rsync \
|
||||||
sqlite3 \
|
sqlite3 \
|
||||||
sudo \
|
sudo \
|
||||||
@@ -141,12 +144,15 @@ echo "[5/7] Installing system services"
|
|||||||
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup
|
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup
|
||||||
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power
|
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power
|
||||||
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
|
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update
|
||||||
|
install -d -m 0755 /usr/local/libexec
|
||||||
|
install -m 0755 "${SCRIPT_DIR}/scripts/pi-car-companion-bluetooth" /usr/local/libexec/pi-car-companion-bluetooth
|
||||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service
|
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service
|
||||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update-check.service" /etc/systemd/system/pi-car-companion-update-check.service
|
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update-check.service" /etc/systemd/system/pi-car-companion-update-check.service
|
||||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
|
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
|
||||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
|
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
|
||||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
|
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
|
||||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service
|
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service
|
||||||
|
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-bluetooth.service" /etc/systemd/system/pi-car-companion-bluetooth.service
|
||||||
install -m 0440 "${SCRIPT_DIR}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
|
install -m 0440 "${SCRIPT_DIR}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
|
||||||
visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null
|
visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null
|
||||||
install -d -m 0755 /etc/avahi/services
|
install -d -m 0755 /etc/avahi/services
|
||||||
@@ -214,11 +220,14 @@ echo "[6/7] Enabling start-on-boot services"
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now avahi-daemon
|
systemctl enable --now avahi-daemon
|
||||||
systemctl enable pi-car-companion.service
|
systemctl enable pi-car-companion.service
|
||||||
|
systemctl enable --now bluetooth.service
|
||||||
|
systemctl enable pi-car-companion-bluetooth.service
|
||||||
systemctl enable pi-car-companion-network-observer.service
|
systemctl enable pi-car-companion-network-observer.service
|
||||||
if grep -qx 'NETWORK_MANAGER_ENABLED=true' "${CONFIG_DIR}/companion.env"; then
|
if grep -qx 'NETWORK_MANAGER_ENABLED=true' "${CONFIG_DIR}/companion.env"; then
|
||||||
systemctl enable --now pi-car-companion-network.service
|
systemctl enable --now pi-car-companion-network.service
|
||||||
fi
|
fi
|
||||||
systemctl restart pi-car-companion.service
|
systemctl restart pi-car-companion.service
|
||||||
|
systemctl restart pi-car-companion-bluetooth.service
|
||||||
systemctl restart pi-car-companion-network-observer.service
|
systemctl restart pi-car-companion-network-observer.service
|
||||||
|
|
||||||
echo "[7/7] Verifying the service"
|
echo "[7/7] Verifying the service"
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
"build": "npm run build --workspaces --if-present",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup scripts/pi-car-companion-power",
|
"check:scripts": "bash -n install.sh scripts/pi-car-companion-update scripts/pi-car-companion-network-setup scripts/pi-car-companion-power && python3 -c \"compile(open('scripts/pi-car-companion-bluetooth', encoding='utf-8').read(), 'scripts/pi-car-companion-bluetooth', 'exec')\"",
|
||||||
"check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build"
|
"check": "npm run check:scripts && npm run lint && npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Executable
+263
@@ -0,0 +1,263 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""BlueZ GATT transport for the Pi Car Companion Android application."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
import dbus
|
||||||
|
import dbus.exceptions
|
||||||
|
import dbus.mainloop.glib
|
||||||
|
import dbus.service
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
BLUEZ = "org.bluez"
|
||||||
|
DBUS_OM = "org.freedesktop.DBus.ObjectManager"
|
||||||
|
DBUS_PROPERTIES = "org.freedesktop.DBus.Properties"
|
||||||
|
GATT_MANAGER = "org.bluez.GattManager1"
|
||||||
|
GATT_SERVICE = "org.bluez.GattService1"
|
||||||
|
GATT_CHARACTERISTIC = "org.bluez.GattCharacteristic1"
|
||||||
|
AD_MANAGER = "org.bluez.LEAdvertisingManager1"
|
||||||
|
ADVERTISEMENT = "org.bluez.LEAdvertisement1"
|
||||||
|
|
||||||
|
SERVICE_UUID = "6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001"
|
||||||
|
RX_UUID = "6c7a0002-7c6d-4f74-9bb0-c5a4e5efc001"
|
||||||
|
TX_UUID = "6c7a0003-7c6d-4f74-9bb0-c5a4e5efc001"
|
||||||
|
API_BASE = os.environ.get("MOBILE_API_BASE", "http://127.0.0.1:8787")
|
||||||
|
MAX_MESSAGE_BYTES = 64 * 1024
|
||||||
|
NOTIFY_CHUNK_BYTES = 180
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidArgs(dbus.exceptions.DBusException):
|
||||||
|
_dbus_error_name = "org.freedesktop.DBus.Error.InvalidArgs"
|
||||||
|
|
||||||
|
|
||||||
|
class NotSupported(dbus.exceptions.DBusException):
|
||||||
|
_dbus_error_name = "org.bluez.Error.NotSupported"
|
||||||
|
|
||||||
|
|
||||||
|
class Application(dbus.service.Object):
|
||||||
|
def __init__(self, bus):
|
||||||
|
self.path = "/org/pi_car_companion"
|
||||||
|
self.services = []
|
||||||
|
super().__init__(bus, self.path)
|
||||||
|
|
||||||
|
def add_service(self, service):
|
||||||
|
self.services.append(service)
|
||||||
|
|
||||||
|
@dbus.service.method(DBUS_OM, out_signature="a{oa{sa{sv}}}")
|
||||||
|
def GetManagedObjects(self):
|
||||||
|
objects = {}
|
||||||
|
for service in self.services:
|
||||||
|
objects[service.path] = service.properties()
|
||||||
|
for characteristic in service.characteristics:
|
||||||
|
objects[characteristic.path] = characteristic.properties()
|
||||||
|
return objects
|
||||||
|
|
||||||
|
|
||||||
|
class Service(dbus.service.Object):
|
||||||
|
def __init__(self, bus, index, uuid_value):
|
||||||
|
self.path = f"/org/pi_car_companion/service{index}"
|
||||||
|
self.uuid = uuid_value
|
||||||
|
self.characteristics = []
|
||||||
|
super().__init__(bus, self.path)
|
||||||
|
|
||||||
|
def add_characteristic(self, characteristic):
|
||||||
|
self.characteristics.append(characteristic)
|
||||||
|
|
||||||
|
def properties(self):
|
||||||
|
return {GATT_SERVICE: {"UUID": self.uuid, "Primary": dbus.Boolean(True), "Characteristics": dbus.Array([item.path for item in self.characteristics], signature="o")}}
|
||||||
|
|
||||||
|
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
|
||||||
|
def GetAll(self, interface):
|
||||||
|
if interface != GATT_SERVICE:
|
||||||
|
raise InvalidArgs()
|
||||||
|
return self.properties()[GATT_SERVICE]
|
||||||
|
|
||||||
|
|
||||||
|
class Characteristic(dbus.service.Object):
|
||||||
|
def __init__(self, bus, index, uuid_value, flags, service):
|
||||||
|
self.path = service.path + f"/char{index}"
|
||||||
|
self.uuid = uuid_value
|
||||||
|
self.flags = flags
|
||||||
|
self.service = service
|
||||||
|
super().__init__(bus, self.path)
|
||||||
|
|
||||||
|
def properties(self):
|
||||||
|
return {GATT_CHARACTERISTIC: {"Service": dbus.ObjectPath(self.service.path), "UUID": self.uuid, "Flags": dbus.Array(self.flags, signature="s")}}
|
||||||
|
|
||||||
|
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
|
||||||
|
def GetAll(self, interface):
|
||||||
|
if interface != GATT_CHARACTERISTIC:
|
||||||
|
raise InvalidArgs()
|
||||||
|
return self.properties()[GATT_CHARACTERISTIC]
|
||||||
|
|
||||||
|
@dbus.service.signal(DBUS_PROPERTIES, signature="sa{sv}as")
|
||||||
|
def PropertiesChanged(self, interface, changed, invalidated):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TxCharacteristic(Characteristic):
|
||||||
|
def __init__(self, bus, service):
|
||||||
|
super().__init__(bus, 1, TX_UUID, ["notify"], service)
|
||||||
|
self.notifying = False
|
||||||
|
self.queue = deque()
|
||||||
|
self.sending = False
|
||||||
|
|
||||||
|
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}")
|
||||||
|
def StartNotify(self, _options):
|
||||||
|
self.notifying = True
|
||||||
|
|
||||||
|
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}")
|
||||||
|
def StopNotify(self, _options):
|
||||||
|
self.notifying = False
|
||||||
|
self.queue.clear()
|
||||||
|
|
||||||
|
def send(self, message):
|
||||||
|
encoded = (json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8")
|
||||||
|
for offset in range(0, len(encoded), NOTIFY_CHUNK_BYTES):
|
||||||
|
self.queue.append(encoded[offset:offset + NOTIFY_CHUNK_BYTES])
|
||||||
|
if not self.sending:
|
||||||
|
self.sending = True
|
||||||
|
GLib.timeout_add(20, self._send_next)
|
||||||
|
|
||||||
|
def _send_next(self):
|
||||||
|
if not self.notifying or not self.queue:
|
||||||
|
self.sending = False
|
||||||
|
return False
|
||||||
|
chunk = self.queue.popleft()
|
||||||
|
value = dbus.Array([dbus.Byte(byte) for byte in chunk], signature="y")
|
||||||
|
self.PropertiesChanged(GATT_CHARACTERISTIC, {"Value": value}, [])
|
||||||
|
return bool(self.queue)
|
||||||
|
|
||||||
|
|
||||||
|
class RxCharacteristic(Characteristic):
|
||||||
|
def __init__(self, bus, service, tx):
|
||||||
|
super().__init__(bus, 0, RX_UUID, ["write", "write-without-response"], service)
|
||||||
|
self.tx = tx
|
||||||
|
self.buffer = bytearray()
|
||||||
|
|
||||||
|
@dbus.service.method(GATT_CHARACTERISTIC, in_signature="aya{sv}")
|
||||||
|
def WriteValue(self, value, _options):
|
||||||
|
self.buffer.extend(bytes(value))
|
||||||
|
if len(self.buffer) > MAX_MESSAGE_BYTES:
|
||||||
|
self.buffer.clear()
|
||||||
|
self.tx.send({"id": None, "ok": False, "error": {"code": "MESSAGE_TOO_LARGE", "message": "Bluetooth message was too large"}})
|
||||||
|
return
|
||||||
|
while b"\n" in self.buffer:
|
||||||
|
raw, _, remaining = self.buffer.partition(b"\n")
|
||||||
|
self.buffer = bytearray(remaining)
|
||||||
|
if raw:
|
||||||
|
threading.Thread(target=self._handle, args=(bytes(raw),), daemon=True).start()
|
||||||
|
|
||||||
|
def _handle(self, raw):
|
||||||
|
request_id = None
|
||||||
|
try:
|
||||||
|
message = json.loads(raw.decode("utf-8"))
|
||||||
|
request_id = message.get("id")
|
||||||
|
operation = message.get("op")
|
||||||
|
token = message.get("token")
|
||||||
|
payload = message.get("payload") or {}
|
||||||
|
method, path, body = route_operation(operation, payload)
|
||||||
|
data = call_api(method, path, body, token)
|
||||||
|
response = {"id": request_id, "ok": True, "data": data}
|
||||||
|
except ProtocolError as error:
|
||||||
|
response = {"id": request_id, "ok": False, "error": {"code": error.code, "message": str(error)}}
|
||||||
|
except Exception:
|
||||||
|
response = {"id": request_id, "ok": False, "error": {"code": "BRIDGE_ERROR", "message": "The Pi Bluetooth bridge could not complete the request"}}
|
||||||
|
GLib.idle_add(self.tx.send, response)
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolError(Exception):
|
||||||
|
def __init__(self, code, message):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def route_operation(operation, payload):
|
||||||
|
if operation == "pair":
|
||||||
|
return "POST", "/internal/mobile/pair", payload
|
||||||
|
if operation == "snapshot":
|
||||||
|
return "GET", "/internal/mobile/snapshot", None
|
||||||
|
if operation == "location":
|
||||||
|
return "POST", "/internal/mobile/location", payload
|
||||||
|
if operation == "launchShortcut":
|
||||||
|
shortcut_id = payload.get("id")
|
||||||
|
if not isinstance(shortcut_id, int) or shortcut_id < 1:
|
||||||
|
raise ProtocolError("INVALID_SHORTCUT", "Shortcut ID was invalid")
|
||||||
|
return "POST", f"/internal/mobile/shortcuts/{shortcut_id}/launch", {}
|
||||||
|
raise ProtocolError("UNKNOWN_OPERATION", "That Bluetooth operation is not available")
|
||||||
|
|
||||||
|
|
||||||
|
def call_api(method, path, body, token):
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if isinstance(token, str):
|
||||||
|
headers["X-Mobile-Token"] = token
|
||||||
|
request = urllib.request.Request(API_BASE + path, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
raw = response.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
try:
|
||||||
|
detail = json.loads(error.read())
|
||||||
|
api_error = detail.get("error", {})
|
||||||
|
raise ProtocolError(api_error.get("code", "REQUEST_FAILED"), api_error.get("message", "The Pi rejected the request"))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
raise ProtocolError("REQUEST_FAILED", "The Pi rejected the request") from error
|
||||||
|
except urllib.error.URLError as error:
|
||||||
|
raise ProtocolError("PI_SERVICE_UNAVAILABLE", "The Pi companion service is unavailable") from error
|
||||||
|
|
||||||
|
|
||||||
|
class Advertisement(dbus.service.Object):
|
||||||
|
def __init__(self, bus):
|
||||||
|
self.path = "/org/pi_car_companion/advertisement0"
|
||||||
|
super().__init__(bus, self.path)
|
||||||
|
|
||||||
|
@dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
|
||||||
|
def GetAll(self, interface):
|
||||||
|
if interface != ADVERTISEMENT:
|
||||||
|
raise InvalidArgs()
|
||||||
|
return {"Type": "peripheral", "ServiceUUIDs": dbus.Array([SERVICE_UUID], signature="s"), "LocalName": "Pi Car"}
|
||||||
|
|
||||||
|
@dbus.service.method(ADVERTISEMENT)
|
||||||
|
def Release(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def find_adapter(bus):
|
||||||
|
objects = dbus.Interface(bus.get_object(BLUEZ, "/"), DBUS_OM).GetManagedObjects()
|
||||||
|
for path, interfaces in objects.items():
|
||||||
|
if GATT_MANAGER in interfaces and AD_MANAGER in interfaces:
|
||||||
|
return path
|
||||||
|
raise RuntimeError("No Bluetooth LE adapter with GATT server support was found")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
|
||||||
|
bus = dbus.SystemBus()
|
||||||
|
adapter_path = find_adapter(bus)
|
||||||
|
adapter = bus.get_object(BLUEZ, adapter_path)
|
||||||
|
dbus.Interface(adapter, DBUS_PROPERTIES).Set("org.bluez.Adapter1", "Powered", dbus.Boolean(True))
|
||||||
|
application = Application(bus)
|
||||||
|
service = Service(bus, 0, SERVICE_UUID)
|
||||||
|
tx = TxCharacteristic(bus, service)
|
||||||
|
rx = RxCharacteristic(bus, service, tx)
|
||||||
|
service.add_characteristic(rx)
|
||||||
|
service.add_characteristic(tx)
|
||||||
|
application.add_service(service)
|
||||||
|
advertisement = Advertisement(bus)
|
||||||
|
dbus.Interface(adapter, GATT_MANAGER).RegisterApplication(application.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error))
|
||||||
|
dbus.Interface(adapter, AD_MANAGER).RegisterAdvertisement(advertisement.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error))
|
||||||
|
GLib.MainLoop().run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
import { CarTelemetryRecorder, type TelemetryPeriod } from "./car-telemetry.js";
|
import { CarTelemetryRecorder, type TelemetryPeriod } from "./car-telemetry.js";
|
||||||
import { openShell } from "./shell.js";
|
import { openShell } from "./shell.js";
|
||||||
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
|
import { systemPowerActionSchema, triggerSystemPower } from "./system.js";
|
||||||
|
import { isLoopbackAddress, registerMobileRoutes } from "./mobile.js";
|
||||||
import "./types.js";
|
import "./types.js";
|
||||||
|
|
||||||
const usernameSchema = z
|
const usernameSchema = z
|
||||||
@@ -189,6 +190,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
|
|
||||||
app.addHook("preValidation", async (request, reply) => {
|
app.addHook("preValidation", async (request, reply) => {
|
||||||
if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) return;
|
if (!["POST", "PUT", "PATCH", "DELETE"].includes(request.method)) return;
|
||||||
|
if (request.url.startsWith("/internal/mobile/") && isLoopbackAddress(request.ip)) return;
|
||||||
|
|
||||||
const origin = request.headers.origin;
|
const origin = request.headers.origin;
|
||||||
let sameHostOrigin = false;
|
let sameHostOrigin = false;
|
||||||
@@ -325,6 +327,8 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||||||
return { loggedOut: true };
|
return { loggedOut: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerMobileRoutes(app, database, config.adb, carTelemetry, requireUser);
|
||||||
|
|
||||||
app.get("/api/status", async (request, reply) => {
|
app.get("/api/status", async (request, reply) => {
|
||||||
if (!requireUser(request, reply)) return;
|
if (!requireUser(request, reply)) return;
|
||||||
return collectSystemStatus(config.adb);
|
return collectSystemStatus(config.adb);
|
||||||
|
|||||||
@@ -70,6 +70,37 @@ function migrate(database: CompanionDatabase): void {
|
|||||||
UNIQUE(target_type, target_value)
|
UNIQUE(target_type, target_value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile_pairing_codes (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile_devices (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile_phone_state (
|
||||||
|
device_id TEXT PRIMARY KEY REFERENCES mobile_devices(id) ON DELETE CASCADE,
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
captured_at TEXT NOT NULL,
|
||||||
|
latitude REAL NOT NULL,
|
||||||
|
longitude REAL NOT NULL,
|
||||||
|
accuracy_meters REAL NOT NULL,
|
||||||
|
altitude_meters REAL,
|
||||||
|
speed_kph REAL,
|
||||||
|
bearing_degrees REAL,
|
||||||
|
battery_percent REAL,
|
||||||
|
network_type TEXT
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS car_telemetry_samples (
|
CREATE TABLE IF NOT EXISTS car_telemetry_samples (
|
||||||
recorded_at INTEGER PRIMARY KEY,
|
recorded_at INTEGER PRIMARY KEY,
|
||||||
speed_kph REAL,
|
speed_kph REAL,
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { randomInt } from "node:crypto";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { AdbConfig } from "./adb.js";
|
||||||
|
import { launchAdbTarget } from "./adb.js";
|
||||||
|
import type { CarTelemetryRecorder } from "./car-telemetry.js";
|
||||||
|
import type { CompanionDatabase } from "./database.js";
|
||||||
|
import { hashToken, randomToken } from "./security.js";
|
||||||
|
import { collectSystemStatus } from "./status.js";
|
||||||
|
|
||||||
|
const pairingSchema = z.object({
|
||||||
|
code: z.string().regex(/^\d{6}$/),
|
||||||
|
deviceId: z.string().uuid(),
|
||||||
|
deviceName: z.string().trim().min(1).max(80)
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationSchema = z.object({
|
||||||
|
capturedAt: z.string().datetime(),
|
||||||
|
latitude: z.number().finite().min(-90).max(90),
|
||||||
|
longitude: z.number().finite().min(-180).max(180),
|
||||||
|
accuracyMeters: z.number().finite().min(0).max(10_000),
|
||||||
|
altitudeMeters: z.number().finite().min(-1_000).max(20_000).nullable().optional(),
|
||||||
|
speedKph: z.number().finite().min(0).max(1_000).nullable().optional(),
|
||||||
|
bearingDegrees: z.number().finite().min(0).max(360).nullable().optional(),
|
||||||
|
batteryPercent: z.number().finite().min(0).max(100).nullable().optional(),
|
||||||
|
networkType: z.enum(["offline", "wifi", "cellular", "ethernet", "other"]).nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
type MobileDevice = { id: string; name: string; createdAt: string; lastSeenAt: string; revokedAt: string | null };
|
||||||
|
type AuthenticatedMobileDevice = { id: string; name: string };
|
||||||
|
|
||||||
|
export function isLoopbackAddress(address: string): boolean {
|
||||||
|
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLoopback(request: FastifyRequest, reply: FastifyReply): boolean {
|
||||||
|
if (isLoopbackAddress(request.ip)) return true;
|
||||||
|
void reply.code(404).send({ error: { code: "NOT_FOUND", message: "Not found" } });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticateMobile(database: CompanionDatabase, request: FastifyRequest, reply: FastifyReply): AuthenticatedMobileDevice | null {
|
||||||
|
if (!requireLoopback(request, reply)) return null;
|
||||||
|
const token = request.headers["x-mobile-token"];
|
||||||
|
if (typeof token !== "string" || token.length < 32) {
|
||||||
|
void reply.code(401).send({ error: { code: "MOBILE_AUTH_REQUIRED", message: "Pair the phone again" } });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const device = database.prepare(
|
||||||
|
"SELECT id, name FROM mobile_devices WHERE token_hash = ? AND revoked_at IS NULL"
|
||||||
|
).get(hashToken(token)) as AuthenticatedMobileDevice | undefined;
|
||||||
|
if (!device) {
|
||||||
|
void reply.code(401).send({ error: { code: "MOBILE_AUTH_INVALID", message: "This phone is no longer paired" } });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
database.prepare("UPDATE mobile_devices SET last_seen_at = ? WHERE id = ?").run(new Date().toISOString(), device.id);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditMobile(database: CompanionDatabase, device: AuthenticatedMobileDevice, action: string, result: "success" | "failure", details: Record<string, unknown> = {}) {
|
||||||
|
database.prepare(
|
||||||
|
"INSERT INTO audit_events (action, result, details_json, created_at) VALUES (?, ?, ?, ?)"
|
||||||
|
).run(action, result, JSON.stringify({ mobileDeviceId: device.id, mobileDeviceName: device.name, ...details }), new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMobileRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
database: CompanionDatabase,
|
||||||
|
adbConfig: AdbConfig,
|
||||||
|
carTelemetry: CarTelemetryRecorder,
|
||||||
|
requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean
|
||||||
|
): void {
|
||||||
|
app.post("/api/mobile/pairing", async (request, reply) => {
|
||||||
|
if (!requireWebUser(request, reply)) return;
|
||||||
|
const code = String(randomInt(100_000, 1_000_000));
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + 10 * 60_000).toISOString();
|
||||||
|
database.prepare(
|
||||||
|
`INSERT INTO mobile_pairing_codes (id, code_hash, expires_at, created_by, created_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET
|
||||||
|
code_hash = excluded.code_hash, expires_at = excluded.expires_at,
|
||||||
|
created_by = excluded.created_by, created_at = excluded.created_at`
|
||||||
|
).run(hashToken(code), expiresAt, request.authUser!.id, now.toISOString());
|
||||||
|
return { code, expiresAt };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/mobile/devices", async (request, reply) => {
|
||||||
|
if (!requireWebUser(request, reply)) return;
|
||||||
|
const devices = database.prepare(
|
||||||
|
`SELECT id, name, created_at AS createdAt, last_seen_at AS lastSeenAt, revoked_at AS revokedAt
|
||||||
|
FROM mobile_devices ORDER BY created_at DESC`
|
||||||
|
).all() as MobileDevice[];
|
||||||
|
return { devices };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>("/api/mobile/devices/:id", async (request, reply) => {
|
||||||
|
if (!requireWebUser(request, reply)) return;
|
||||||
|
const result = database.prepare("UPDATE mobile_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL")
|
||||||
|
.run(new Date().toISOString(), request.params.id);
|
||||||
|
if (result.changes === 0) return reply.code(404).send({ error: { code: "MOBILE_DEVICE_NOT_FOUND", message: "Phone not found" } });
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/internal/mobile/pair", { config: { rateLimit: { max: 8, timeWindow: "1 minute" } } }, async (request, reply) => {
|
||||||
|
if (!requireLoopback(request, reply)) return;
|
||||||
|
const parsed = pairingSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PAIRING_REQUEST", message: "Enter the six-digit pairing code" } });
|
||||||
|
const row = database.prepare("SELECT code_hash, expires_at FROM mobile_pairing_codes WHERE id = 1")
|
||||||
|
.get() as { code_hash: string; expires_at: string } | undefined;
|
||||||
|
if (!row || row.code_hash !== hashToken(parsed.data.code) || row.expires_at <= new Date().toISOString()) {
|
||||||
|
return reply.code(401).send({ error: { code: "PAIRING_CODE_INVALID", message: "The pairing code is invalid or expired" } });
|
||||||
|
}
|
||||||
|
const token = randomToken();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
database.transaction(() => {
|
||||||
|
database.prepare(
|
||||||
|
`INSERT INTO mobile_devices (id, name, token_hash, created_at, last_seen_at, revoked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NULL) ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name, token_hash = excluded.token_hash,
|
||||||
|
last_seen_at = excluded.last_seen_at, revoked_at = NULL`
|
||||||
|
).run(parsed.data.deviceId, parsed.data.deviceName, hashToken(token), now, now);
|
||||||
|
database.prepare("DELETE FROM mobile_pairing_codes WHERE id = 1").run();
|
||||||
|
})();
|
||||||
|
return { token, deviceId: parsed.data.deviceId, pairedAt: now };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/internal/mobile/snapshot", async (request, reply) => {
|
||||||
|
const device = authenticateMobile(database, request, reply);
|
||||||
|
if (!device) return;
|
||||||
|
const [system, car] = await Promise.all([
|
||||||
|
collectSystemStatus(adbConfig),
|
||||||
|
carTelemetry.readLive().catch(() => null)
|
||||||
|
]);
|
||||||
|
const shortcuts = database.prepare(
|
||||||
|
"SELECT id, name, target_type AS type, target_value AS value FROM adb_shortcuts ORDER BY created_at, id"
|
||||||
|
).all();
|
||||||
|
const phone = database.prepare(
|
||||||
|
`SELECT received_at AS receivedAt, captured_at AS capturedAt, latitude, longitude,
|
||||||
|
accuracy_meters AS accuracyMeters, altitude_meters AS altitudeMeters, speed_kph AS speedKph,
|
||||||
|
bearing_degrees AS bearingDegrees, battery_percent AS batteryPercent, network_type AS networkType
|
||||||
|
FROM mobile_phone_state WHERE device_id = ?`
|
||||||
|
).get(device.id) ?? null;
|
||||||
|
return { protocolVersion: 1, collectedAt: new Date().toISOString(), system, car, statistics: carTelemetry.store.overview(), shortcuts, phone };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/internal/mobile/location", async (request, reply) => {
|
||||||
|
const device = authenticateMobile(database, request, reply);
|
||||||
|
if (!device) return;
|
||||||
|
const parsed = locationSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PHONE_LOCATION", message: "The phone location was invalid" } });
|
||||||
|
const value = parsed.data;
|
||||||
|
database.prepare(
|
||||||
|
`INSERT INTO mobile_phone_state
|
||||||
|
(device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
|
||||||
|
speed_kph, bearing_degrees, battery_percent, network_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET
|
||||||
|
received_at = excluded.received_at, captured_at = excluded.captured_at,
|
||||||
|
latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters,
|
||||||
|
altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph,
|
||||||
|
bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent,
|
||||||
|
network_type = excluded.network_type`
|
||||||
|
).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters,
|
||||||
|
value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null,
|
||||||
|
value.batteryPercent ?? null, value.networkType ?? null);
|
||||||
|
return { accepted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>("/internal/mobile/shortcuts/:id/launch", async (request, reply) => {
|
||||||
|
const device = authenticateMobile(database, request, reply);
|
||||||
|
if (!device) return;
|
||||||
|
const id = Number(request.params.id);
|
||||||
|
const shortcut = Number.isInteger(id) ? database.prepare(
|
||||||
|
"SELECT target_type AS type, target_value AS value FROM adb_shortcuts WHERE id = ?"
|
||||||
|
).get(id) as { type: "component" | "package"; value: string } | undefined : undefined;
|
||||||
|
if (!shortcut) return reply.code(404).send({ error: { code: "ADB_SHORTCUT_NOT_FOUND", message: "Shortcut not found" } });
|
||||||
|
try {
|
||||||
|
await launchAdbTarget(adbConfig, shortcut);
|
||||||
|
auditMobile(database, device, "mobile.adb-launch", "success", { shortcutId: id });
|
||||||
|
return { launched: true };
|
||||||
|
} catch {
|
||||||
|
auditMobile(database, device, "mobile.adb-launch", "failure", { shortcutId: id });
|
||||||
|
return reply.code(503).send({ error: { code: "ADB_LAUNCH_FAILED", message: "The shortcut could not be opened" } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -341,6 +341,66 @@ describe("authentication boundary", () => {
|
|||||||
expect(removed.statusCode).toBe(204);
|
expect(removed.statusCode).toBe(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pairs, updates, and revokes a Bluetooth mobile companion", async () => {
|
||||||
|
const app = await createApp();
|
||||||
|
const csrfToken = await csrf(app);
|
||||||
|
await setup(app, csrfToken);
|
||||||
|
const login = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
headers: mutationHeaders(csrfToken),
|
||||||
|
payload: { username: "owner", password: "Correct-horse1" }
|
||||||
|
});
|
||||||
|
const session = login.cookies.find((item) => item.name === SESSION_COOKIE)?.value ?? "";
|
||||||
|
const cookie = `${CSRF_COOKIE}=${csrfToken}; ${SESSION_COOKIE}=${session}`;
|
||||||
|
const webHeaders = { ...mutationHeaders(csrfToken), cookie };
|
||||||
|
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/mobile/devices" })).statusCode).toBe(401);
|
||||||
|
const pairing = await app.inject({ method: "POST", url: "/api/mobile/pairing", headers: webHeaders });
|
||||||
|
expect(pairing.statusCode).toBe(200);
|
||||||
|
const code = pairing.json<{ code: string }>().code;
|
||||||
|
expect(code).toMatch(/^\d{6}$/);
|
||||||
|
|
||||||
|
const deviceId = "2f9ca5d6-28cd-45a7-a7f5-27a13dc9eb98";
|
||||||
|
const paired = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/internal/mobile/pair",
|
||||||
|
payload: { code, deviceId, deviceName: "Owner phone" }
|
||||||
|
});
|
||||||
|
expect(paired.statusCode).toBe(200);
|
||||||
|
const mobileToken = paired.json<{ token: string }>().token;
|
||||||
|
const mobileHeaders = { "x-mobile-token": mobileToken };
|
||||||
|
|
||||||
|
const location = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/internal/mobile/location",
|
||||||
|
headers: mobileHeaders,
|
||||||
|
payload: {
|
||||||
|
capturedAt: "2026-07-31T20:00:00.000Z",
|
||||||
|
latitude: 55.6761,
|
||||||
|
longitude: 12.5683,
|
||||||
|
accuracyMeters: 4.2,
|
||||||
|
speedKph: 42.5,
|
||||||
|
batteryPercent: 76,
|
||||||
|
networkType: "cellular"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(location.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const snapshot = await app.inject({ method: "GET", url: "/internal/mobile/snapshot", headers: mobileHeaders });
|
||||||
|
expect(snapshot.statusCode).toBe(200);
|
||||||
|
expect(snapshot.json()).toMatchObject({
|
||||||
|
protocolVersion: 1,
|
||||||
|
phone: { latitude: 55.6761, speedKph: 42.5, networkType: "cellular" },
|
||||||
|
shortcuts: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const devices = await app.inject({ method: "GET", url: "/api/mobile/devices", headers: { cookie } });
|
||||||
|
expect(devices.json()).toMatchObject({ devices: [{ id: deviceId, name: "Owner phone", revokedAt: null }] });
|
||||||
|
expect((await app.inject({ method: "DELETE", url: `/api/mobile/devices/${deviceId}`, headers: webHeaders })).statusCode).toBe(204);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/internal/mobile/snapshot", headers: mobileHeaders })).statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes only allowlisted jobs and authenticated run history", async () => {
|
it("exposes only allowlisted jobs and authenticated run history", async () => {
|
||||||
const app = await createApp();
|
const app = await createApp();
|
||||||
const token = await csrf(app);
|
const token = await csrf(app);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Pi Car Companion Bluetooth bridge
|
||||||
|
After=bluetooth.service pi-car-companion.service
|
||||||
|
Requires=bluetooth.service
|
||||||
|
Wants=pi-car-companion.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/libexec/pi-car-companion-bluetooth
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+73
-3
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from
|
|||||||
import {
|
import {
|
||||||
ArrowClockwise,
|
ArrowClockwise,
|
||||||
AndroidLogo,
|
AndroidLogo,
|
||||||
|
Bluetooth,
|
||||||
CarProfile,
|
CarProfile,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Cpu,
|
Cpu,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
SignOut,
|
SignOut,
|
||||||
TerminalWindow,
|
TerminalWindow,
|
||||||
|
Trash,
|
||||||
WarningCircle,
|
WarningCircle,
|
||||||
WifiHigh
|
WifiHigh
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
@@ -26,12 +28,15 @@ import {
|
|||||||
getNetworkStatus,
|
getNetworkStatus,
|
||||||
getNetworkActivity,
|
getNetworkActivity,
|
||||||
getUpdateStatus,
|
getUpdateStatus,
|
||||||
|
getMobileDevices,
|
||||||
post,
|
post,
|
||||||
|
revokeMobileDevice,
|
||||||
type AuthState,
|
type AuthState,
|
||||||
type SystemStatus,
|
type SystemStatus,
|
||||||
type NetworkStatus,
|
type NetworkStatus,
|
||||||
type NetworkActivity,
|
type NetworkActivity,
|
||||||
type UpdateStatus
|
type UpdateStatus,
|
||||||
|
type MobileDevice
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
const ShellTerminal = lazy(async () => {
|
const ShellTerminal = lazy(async () => {
|
||||||
@@ -322,7 +327,7 @@ function portLabel(port: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||||
const [tab, setTab] = useState<"network" | "system" | "shell">("network");
|
const [tab, setTab] = useState<"network" | "system" | "mobile" | "shell">("network");
|
||||||
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
||||||
const [network, setNetwork] = useState<NetworkStatus | null>(null);
|
const [network, setNetwork] = useState<NetworkStatus | null>(null);
|
||||||
const [updateError, setUpdateError] = useState("");
|
const [updateError, setUpdateError] = useState("");
|
||||||
@@ -338,6 +343,9 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
const [ssidTouched, setSsidTouched] = useState(false);
|
const [ssidTouched, setSsidTouched] = useState(false);
|
||||||
const [confirmHotspot, setConfirmHotspot] = useState(false);
|
const [confirmHotspot, setConfirmHotspot] = useState(false);
|
||||||
const [networkNotice, setNetworkNotice] = useState("");
|
const [networkNotice, setNetworkNotice] = useState("");
|
||||||
|
const [mobileDevices, setMobileDevices] = useState<MobileDevice[]>([]);
|
||||||
|
const [pairingCode, setPairingCode] = useState<{ code: string; expiresAt: string } | null>(null);
|
||||||
|
const [mobileError, setMobileError] = useState("");
|
||||||
const running = update?.state === "checking" || update?.state === "building";
|
const running = update?.state === "checking" || update?.state === "building";
|
||||||
|
|
||||||
const refreshUpdate = useCallback(async () => {
|
const refreshUpdate = useCallback(async () => {
|
||||||
@@ -358,15 +366,49 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshMobile = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setMobileDevices(await getMobileDevices());
|
||||||
|
setMobileError("");
|
||||||
|
} catch (caught) {
|
||||||
|
setMobileError(caught instanceof Error ? caught.message : "Paired phones could not be loaded");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshUpdate();
|
void refreshUpdate();
|
||||||
void refreshNetwork();
|
void refreshNetwork();
|
||||||
|
void refreshMobile();
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
void refreshUpdate();
|
void refreshUpdate();
|
||||||
void refreshNetwork();
|
void refreshNetwork();
|
||||||
}, 3_000);
|
}, 3_000);
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [refreshNetwork, refreshUpdate]);
|
}, [refreshMobile, refreshNetwork, refreshUpdate]);
|
||||||
|
|
||||||
|
async function createPairingCode() {
|
||||||
|
setRequesting(true);
|
||||||
|
setMobileError("");
|
||||||
|
try {
|
||||||
|
setPairingCode(await post<{ code: string; expiresAt: string }>("/api/mobile/pairing", {}, csrfToken));
|
||||||
|
} catch (caught) {
|
||||||
|
setMobileError(caught instanceof Error ? caught.message : "A pairing code could not be created");
|
||||||
|
} finally {
|
||||||
|
setRequesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokePhone(device: MobileDevice) {
|
||||||
|
setRequesting(true);
|
||||||
|
try {
|
||||||
|
await revokeMobileDevice(device.id, csrfToken);
|
||||||
|
await refreshMobile();
|
||||||
|
} catch (caught) {
|
||||||
|
setMobileError(caught instanceof Error ? caught.message : "The phone could not be revoked");
|
||||||
|
} finally {
|
||||||
|
setRequesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ssidTouched && network?.hotspotSsid) setHotspotSsid(network.hotspotSsid);
|
if (!ssidTouched && network?.hotspotSsid) setHotspotSsid(network.hotspotSsid);
|
||||||
@@ -489,6 +531,7 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
<nav className="settings-tabs" aria-label="Settings categories" role="tablist">
|
<nav className="settings-tabs" aria-label="Settings categories" role="tablist">
|
||||||
<button role="tab" aria-selected={tab === "network"} className={tab === "network" ? "active" : ""} onClick={() => setTab("network")}><WifiHigh size={20} />Network</button>
|
<button role="tab" aria-selected={tab === "network"} className={tab === "network" ? "active" : ""} onClick={() => setTab("network")}><WifiHigh size={20} />Network</button>
|
||||||
<button role="tab" aria-selected={tab === "system"} className={tab === "system" ? "active" : ""} onClick={() => setTab("system")}><Gear size={20} />System</button>
|
<button role="tab" aria-selected={tab === "system"} className={tab === "system" ? "active" : ""} onClick={() => setTab("system")}><Gear size={20} />System</button>
|
||||||
|
<button role="tab" aria-selected={tab === "mobile"} className={tab === "mobile" ? "active" : ""} onClick={() => setTab("mobile")}><Bluetooth size={20} />Mobile</button>
|
||||||
<button role="tab" aria-selected={tab === "shell"} className={tab === "shell" ? "active" : ""} onClick={() => setTab("shell")}><TerminalWindow size={20} />Shell</button>
|
<button role="tab" aria-selected={tab === "shell"} className={tab === "shell" ? "active" : ""} onClick={() => setTab("shell")}><TerminalWindow size={20} />Shell</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -612,6 +655,33 @@ function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
|||||||
</section>
|
</section>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
{tab === "mobile" && <div className="settings-layout mobile-settings-layout" role="tabpanel">
|
||||||
|
<section className="settings-panel mobile-pairing-panel">
|
||||||
|
<div className="settings-title">
|
||||||
|
<div className="settings-icon"><Bluetooth size={30} weight="duotone" /></div>
|
||||||
|
<div><h2>Android companion</h2><p>Pair the phone app over Bluetooth to receive live Pi and car status and contribute phone GPS.</p></div>
|
||||||
|
</div>
|
||||||
|
{mobileError && <div className="form-error" role="alert"><WarningCircle size={20} />{mobileError}</div>}
|
||||||
|
{pairingCode ? <div className="pairing-code" role="status">
|
||||||
|
<span>Enter this code in the Android app</span>
|
||||||
|
<strong>{pairingCode.code}</strong>
|
||||||
|
<small>Expires {new Date(pairingCode.expiresAt).toLocaleTimeString()}</small>
|
||||||
|
</div> : <p className="settings-note">Codes work once and expire after ten minutes. The phone receives a revocable credential stored in Android Keystore.</p>}
|
||||||
|
<button className="primary-button" onClick={() => void createPairingCode()} disabled={requesting}><Bluetooth size={22} />{pairingCode ? "Generate another code" : "Pair a phone"}</button>
|
||||||
|
</section>
|
||||||
|
<section className="settings-panel mobile-devices-panel">
|
||||||
|
<div className="panel-heading"><div><h2>Paired phones</h2><p>Revoke a phone immediately if it is lost or replaced.</p></div></div>
|
||||||
|
<div className="mobile-device-list">
|
||||||
|
{mobileDevices.filter((device) => !device.revokedAt).map((device) => <div className="mobile-device" key={device.id}>
|
||||||
|
<Bluetooth size={22} />
|
||||||
|
<div><strong>{device.name}</strong><small>Last seen {new Date(device.lastSeenAt).toLocaleString()}</small></div>
|
||||||
|
<button className="icon-button" aria-label={`Revoke ${device.name}`} onClick={() => void revokePhone(device)} disabled={requesting}><Trash size={19} /></button>
|
||||||
|
</div>)}
|
||||||
|
{mobileDevices.every((device) => device.revokedAt) && <div className="activity-empty">No phones are paired yet.</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>}
|
||||||
|
|
||||||
{tab === "shell" && <div role="tabpanel"><Suspense fallback={<div className="shell-loading">Loading terminal…</div>}><ShellTerminal csrfToken={csrfToken} /></Suspense></div>}
|
{tab === "shell" && <div role="tabpanel"><Suspense fallback={<div className="shell-loading">Loading terminal…</div>}><ShellTerminal csrfToken={csrfToken} /></Suspense></div>}
|
||||||
|
|
||||||
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
|
{confirming && <div className="modal-backdrop" role="presentation" onMouseDown={() => setConfirming(false)}>
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ export type AdbShortcut = AdbTarget & {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MobileDevice = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
revokedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type CarTelemetry = {
|
export type CarTelemetry = {
|
||||||
available: true;
|
available: true;
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
@@ -247,6 +255,21 @@ export async function getAdbShortcuts(): Promise<AdbShortcut[]> {
|
|||||||
return response.shortcuts;
|
return response.shortcuts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMobileDevices(): Promise<MobileDevice[]> {
|
||||||
|
const response = await parseResponse<{ devices: MobileDevice[] }>(
|
||||||
|
await fetch("/api/mobile/devices", { credentials: "same-origin" })
|
||||||
|
);
|
||||||
|
return response.devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> {
|
||||||
|
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "X-CSRF-Token": csrfToken }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadApk(file: File, csrfToken: string): Promise<void> {
|
export async function uploadApk(file: File, csrfToken: string): Promise<void> {
|
||||||
await parseResponse(
|
await parseResponse(
|
||||||
await fetch("/api/adb/install", {
|
await fetch("/api/adb/install", {
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
|||||||
.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; }
|
.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; }
|
||||||
|
|
||||||
.settings-page { min-width: 0; width: 100%; display: grid; gap: 18px; }
|
.settings-page { min-width: 0; width: 100%; display: grid; gap: 18px; }
|
||||||
|
.mobile-settings-layout { grid-template-columns: minmax(340px, 0.8fr) minmax(420px, 1.2fr); }
|
||||||
|
.pairing-code { display: grid; justify-items: center; gap: 8px; margin: 28px 0 20px; padding: 24px; background: #10140f; border: 1px solid var(--line); border-radius: 12px; }
|
||||||
|
.pairing-code span, .pairing-code small { color: var(--muted); font-size: 0.78rem; }
|
||||||
|
.pairing-code strong { color: var(--accent); font: 800 2.7rem/1 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.18em; }
|
||||||
|
.mobile-device-list { display: grid; gap: 9px; margin-top: 22px; }
|
||||||
|
.mobile-device { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; min-height: 65px; padding: 10px 12px; background: #121610; border-radius: 10px; }
|
||||||
|
.mobile-device > svg { color: var(--accent); }
|
||||||
|
.mobile-device div { min-width: 0; display: grid; gap: 4px; }
|
||||||
|
.mobile-device small { overflow: hidden; color: var(--muted); font-size: 0.72rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.settings-page > [role="tabpanel"] { min-width: 0; width: 100%; max-width: 100%; }
|
.settings-page > [role="tabpanel"] { min-width: 0; width: 100%; max-width: 100%; }
|
||||||
.settings-tabs { width: fit-content; display: flex; gap: 5px; padding: 5px; background: #121610; border: 1px solid var(--line); border-radius: 12px; }
|
.settings-tabs { width: fit-content; display: flex; gap: 5px; padding: 5px; background: #121610; border: 1px solid var(--line); border-radius: 12px; }
|
||||||
.settings-tabs button { min-height: 48px; display: inline-flex; align-items: center; gap: 9px; padding: 0 19px; color: var(--muted); background: transparent; border: 0; border-radius: 8px; font-weight: 700; cursor: pointer; transition: color 160ms ease, background 160ms ease, transform 120ms ease; }
|
.settings-tabs button { min-height: 48px; display: inline-flex; align-items: center; gap: 9px; padding: 0 19px; color: var(--muted); background: transparent; border: 0; border-radius: 8px; font-weight: 700; cursor: pointer; transition: color 160ms ease, background 160ms ease, transform 120ms ease; }
|
||||||
|
|||||||
Reference in New Issue
Block a user