feat: add Bluetooth Android companion

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