From ae7c3ec308748663ab9ec81a0d124f6403f58ce6 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 29 Sep 2026 18:07:48 -0700 Subject: [PATCH] Voice assistant, vehicle data, and template rendering fixes Push-to-talk voice control, gated behind experimental settings alongside a new raw vehicle sensor screen. Spotify now shows as partial instead of working since its browse tile rejects Gearslip as a host. Fixed a gap where a live nav app couldn't take a fresh destination without a full reconnect. --- README.md | 3 - gearslip/build.gradle.kts | 4 + .../res/drawable/ic_launcher_background.xml | 15 ++ .../app/seb3thehacker/gearslip/AppSettings.kt | 31 ++- .../gearslip/GearslipActivity.kt | 11 +- .../app/seb3thehacker/gearslip/GearslipLog.kt | 8 +- .../seb3thehacker/gearslip/GearslipRunner.kt | 112 ++++++++++- .../gearslip/ServiceDiscovery.kt | 65 +++++- .../gearslip/car/AssistantCommands.kt | 182 +++++++++++++++++ .../gearslip/car/AssistantOverlay.kt | 143 +++++++++++++ .../seb3thehacker/gearslip/car/CarAppStage.kt | 25 --- .../gearslip/car/CarAssistant.kt | 188 +++++++++++++++++ .../gearslip/car/CarHomeScreen.kt | 49 ++--- .../seb3thehacker/gearslip/car/CarKeyboard.kt | 80 +++++++- .../seb3thehacker/gearslip/car/CarLauncher.kt | 99 +++++++-- .../gearslip/car/CarNavigator.kt | 14 ++ .../seb3thehacker/gearslip/car/CarSensors.kt | 95 +++++++++ .../seb3thehacker/gearslip/car/CarServices.kt | 97 ++++++++- .../seb3thehacker/gearslip/car/CarSettings.kt | 61 ++++++ .../gearslip/car/CarSettingsScreen.kt | 81 ++++++++ .../app/seb3thehacker/gearslip/car/CarUi.kt | 152 ++++++++++++-- .../seb3thehacker/gearslip/car/MediaScreen.kt | 21 +- .../gearslip/car/NotificationsScreen.kt | 5 +- .../gearslip/car/PhoneDialerScreen.kt | 16 +- .../gearslip/car/TemplateContent.kt | 189 +++++++++++++----- .../gearslip/car/TemplateParts.kt | 87 +++++--- .../gearslip/car/VehicleDataScreen.kt | 69 +++++++ .../app/seb3thehacker/gearslip/car/WebApp.kt | 8 + .../gearslip/host/CarAppConnection.kt | 35 +++- .../seb3thehacker/gearslip/host/CarModels.kt | 46 ++++- .../seb3thehacker/gearslip/host/KnownApps.kt | 9 + .../gearslip/host/LocationKeepAlive.kt | 12 +- .../seb3thehacker/gearslip/media/Lyrics.kt | 48 ++++- .../seb3thehacker/gearslip/ui/GearslipApp.kt | 97 +++++++-- .../seb3thehacker/gearslip/ui/HelpScreen.kt | 58 +++--- .../seb3thehacker/gearslip/ui/HomeScreen.kt | 129 ++++++------ .../gearslip/ui/SettingsScreen.kt | 30 +++ .../gearslip/ui/SetupGuideScreen.kt | 11 + .../src/main/res/drawable/navigation_24.xml | 9 + 39 files changed, 2045 insertions(+), 349 deletions(-) create mode 100644 gearslip/src/debug/res/drawable/ic_launcher_background.xml create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantCommands.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAssistant.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSensors.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/VehicleDataScreen.kt create mode 100644 gearslip/src/main/res/drawable/navigation_24.xml diff --git a/README.md b/README.md index 828c38d..8d7dfb2 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,6 @@ and Gearslip renders that template natively on the car screen. Nothing is mirror the phone. For apps without a car interface, Gearslip offers a separate screen-sharing mode. -The author reverse-engineered the protocol for interoperability, so that a client other -than Google's own can talk to a car head unit. - ## Features - **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts index 288b0ca..f858ebc 100644 --- a/gearslip/build.gradle.kts +++ b/gearslip/build.gradle.kts @@ -55,6 +55,10 @@ android { } } + buildFeatures { + buildConfig = true + } + // Keeps build output out of the module folder: /build/gearslip/... instead of // /gearslip/build/..., so everything this project produces lands under one // root-level build/ directory. diff --git a/gearslip/src/debug/res/drawable/ic_launcher_background.xml b/gearslip/src/debug/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..989a5be --- /dev/null +++ b/gearslip/src/debug/res/drawable/ic_launcher_background.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt index 2b5adb1..2c9dfbb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt @@ -1,6 +1,8 @@ package app.seb3thehacker.gearslip +import android.content.ComponentName import android.content.Context +import android.content.pm.PackageManager /** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */ object AppSettings { @@ -9,8 +11,8 @@ object AppSettings { private const val KEY_STARTUP_URL = "startup_url" private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning" private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup" - private const val KEY_DEBUG_MODE = "debug_mode" private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup" + private const val KEY_GEARSLIP_ENABLED = "gearslip_enabled" private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -55,14 +57,27 @@ object AppSettings { } /** - * Debug mode: full protocol tracing in the log, and the developer screens (live logs, car - * preview) on the home screen. On by default while the project is pre-release. + * Whether Gearslip is allowed to claim the USB accessory connection. Off leaves the app + * installed but disables the GearslipUsbAccessory alias that answers + * USB_ACCESSORY_ATTACHED, so a real Android Auto head unit (or Google's own app) gets the + * connection instead - the two fight over it otherwise, since only one app can win the + * accessory handoff. The alias is separate from GearslipActivity itself - the activity that + * shows this very switch - so turning it off can't disable the screen you're looking at. + * On by default. */ - fun debugMode(context: Context): Boolean = - prefs(context).getBoolean(KEY_DEBUG_MODE, true) + fun gearslipEnabled(context: Context): Boolean = + prefs(context).getBoolean(KEY_GEARSLIP_ENABLED, true) - fun setDebugMode(context: Context, value: Boolean) { - prefs(context).edit().putBoolean(KEY_DEBUG_MODE, value).apply() - GearslipLog.debug = value + fun setGearslipEnabled(context: Context, value: Boolean) { + prefs(context).edit().putBoolean(KEY_GEARSLIP_ENABLED, value).apply() + val appContext = context.applicationContext + val component = ComponentName(appContext.packageName, "${appContext.packageName}.GearslipUsbAccessory") + val state = if (value) { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + context.applicationContext.packageManager + .setComponentEnabledSetting(component, state, PackageManager.DONT_KILL_APP) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index 9e95598..29207aa 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -256,10 +256,13 @@ class GearslipActivity : ComponentActivity(), Projection { } private fun start(accessory: UsbAccessory) { - if (worker?.isAlive == true) { - GearslipLog.w("a run is already in progress") - return - } + // A fresh ATTACHED intent means the system wants a new session, whatever state the old + // one is in - closing first guarantees openAccessory() below never fights a descriptor + // this same process forgot to release. The old worker thread often isn't alive by this + // point anyway (its read loop already died when the head unit dropped the transport), + // but the accessory it opened was never explicitly closed, which is what actually blocks + // a re-open - a dead thread does not release the file descriptor on its own. + closeAccessory() describe(accessory) val manager = getSystemService(UsbManager::class.java) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt index 2b1fc1f..c0df6f5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt @@ -32,9 +32,9 @@ object GearslipLog { private var file: File? = null /** - * Set from [AppSettings.debugMode]. Off drops the noisy trace output (hex dumps, and - * logcat copies of info lines); warnings, errors and the file copy are always kept, so a - * crash report is never empty. + * Off drops the noisy trace output (hex dumps, and logcat copies of info lines); warnings, + * errors and the file copy are always kept, so a crash report is never empty. Always on for + * now, while the project is pre-release. */ @Volatile var debug = true @@ -47,13 +47,11 @@ object GearslipLog { val version: StateFlow = liveVersion fun init(context: Context) { - debug = AppSettings.debugMode(context) val dir = context.getExternalFilesDir(null) ?: return val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) file = File(dir, "gearslip-$name.log") i("log file: ${file?.absolutePath}") SessionReport.init(context) - i("debug mode: $debug") } fun snapshot(): List = synchronized(live) { live.toList() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index b6d79cb..007aa80 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -2,7 +2,11 @@ package app.seb3thehacker.gearslip import android.os.Build import app.seb3thehacker.gearslip.audio.AudioLink +import app.seb3thehacker.gearslip.car.CarAssistant import app.seb3thehacker.gearslip.car.CarEnvironment +import app.seb3thehacker.gearslip.car.CarSensors +import app.seb3thehacker.gearslip.car.CarServices +import app.seb3thehacker.gearslip.car.CarSettings import java.io.InputStream import java.io.OutputStream @@ -50,6 +54,8 @@ class GearslipRunner( @Volatile private var framesSent = 0 @Volatile private var dropped = 0 @Volatile private var inputChannelId: Int = -1 + @Volatile private var sensorChannelId: Int = -1 + private var sensorTypes: List = emptyList() @Volatile private var audioLink: AudioLink? = null private var audioMessagesSeen = 0 private var touchWidth = 0 @@ -111,6 +117,7 @@ class GearslipRunner( videoSource = null audioLink?.close() audioLink = null + CarSensors.clear() projection?.onProjectionStopped() SessionStatus.disconnected() } @@ -142,6 +149,8 @@ class GearslipRunner( onVideoMessage(messageId, body) } else if (frame.channel == inputChannelId) { onInputMessage(messageId, body) + } else if (frame.channel == sensorChannelId) { + onSensorMessage(messageId, body) } else if (frame.channel == audioLink?.channelId) { // The audio channel is the one still being reverse-engineered, and its first few // replies are what tell us why. Raw bytes for those, then quiet. @@ -350,6 +359,7 @@ class GearslipRunner( openChannel(video.serviceId) startAudioChannel(serviceDiscoveryResponse) + startSensorChannel(serviceDiscoveryResponse) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse) if (input == null) { @@ -360,7 +370,7 @@ class GearslipRunner( touchHeight = input.touchHeight log.i( "input service: channel=${input.serviceId} touchscreen=" + - "${input.touchWidth}x${input.touchHeight}", + "${input.touchWidth}x${input.touchHeight} keycodes=${input.keycodesSupported}", ) openChannel(input.serviceId) } @@ -395,6 +405,57 @@ class GearslipRunner( openChannel(media.serviceId) } + /** + * Every sensor type the head unit advertised, subscribed to at once - there is no screen + * to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers + * every request with "unsupported" today per BUILDING_APPS.md's line to third-party apps, but + * that's a promise made to apps Gearslip hosts, not to what Gearslip itself may ask a real + * head unit for over this same channel. + */ + private fun startSensorChannel(serviceDiscoveryResponse: ByteArray) { + val sensors = ServiceDiscovery.findSensorService(serviceDiscoveryResponse) + if (sensors == null) { + log.i("no sensor service advertised - this head unit offers no vehicle data channel") + return + } + sensorChannelId = sensors.serviceId + sensorTypes = sensors.types + log.i("sensor service: channel=${sensors.serviceId} types=${sensors.types}") + CarSensors.onDiscovered(sensors.types) + openChannel(sensors.serviceId) + } + + private fun onSensorMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> { + val status = Protobuf.readInt32Field(body, 1) + log.i("<- ChannelOpenResponse (sensor): status=$status") + if (status != 0) { + log.w("head unit refused the sensor channel (status=$status)") + return + } + // SensorRequest { required int32 sensor_type = 1; } + sensorTypes.forEach { type -> + send( + MSG_SENSOR_START_REQUEST, Protobuf.varintField(1, type.toLong()), + encrypted = true, channel = sensorChannelId, + ) + log.i("-> SensorStartRequest(sensor_type=$type)") + } + } + + MSG_SENSOR_START_RESPONSE -> + log.i("<- SensorStartResponse: status=${Protobuf.readInt32Field(body, 1)}") + + MSG_SENSOR_EVENT_INDICATION -> { + CarSensors.onEvent(body) + log.i("<- SensorEvent:\n" + Protobuf.describe(body)) + } + + else -> log.i("<- unhandled sensor message id=$messageId (${body.size} bytes)") + } + } + private fun openChannel(serviceId: Int) { val request = Protobuf.varintField(1, 0L) + // priority (sint32 zigzag: 0 -> 0) Protobuf.varintField(2, serviceId.toLong()) // service_id @@ -425,9 +486,9 @@ class GearslipRunner( MSG_INPUT_REPORT -> { val report = Wire.fields(body) + Wire.bytes(report, 4)?.let(::onKeyEvent) val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7) if (touch == null) { - log.i("<- InputReport with no touch payload (key or rotary event)") return } val touchFields = Wire.fields(touch) @@ -460,6 +521,39 @@ class GearslipRunner( } } + /** + * Steering-wheel and head-unit buttons: InputReport.key_event (field 4), shaped as + * KeyEvent { repeated Key keys = 1; } + * Key { keycode = 1; down = 2; metastate = 3; longpress = 4; } + * + * Acted on the down edge only, so one press is one action. These are the same numeric + * codes as android.view.KeyEvent (the head unit reuses Android's own keycode space), which + * is why they line up with the constants below without needing a translation table. + */ + private fun onKeyEvent(keyEvent: ByteArray) { + for (key in Wire.allBytes(Wire.fields(keyEvent), 1)) { + val fields = Wire.fields(key) + val keycode = Wire.varint(fields, 1)?.toInt() ?: continue + val down = Wire.varint(fields, 2) == 1L + if (!down) continue + log.i("<- key event: keycode=$keycode") + when (keycode) { + KEYCODE_MEDIA_PLAY_PAUSE -> CarServices.media.togglePlay() + KEYCODE_MEDIA_NEXT -> CarServices.media.next() + KEYCODE_MEDIA_PREVIOUS -> CarServices.media.previous() + KEYCODE_VOICE_ASSIST, KEYCODE_SEARCH -> { + if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.voiceAssistantEnabled.value) { + log.i("voice/PTT button pressed - starting the assistant") + CarAssistant.start() + } else { + log.i("voice/PTT button pressed - assistant is off in settings, ignoring") + } + } + else -> log.i("unhandled keycode=$keycode") + } + } + } + private fun onVideoMessage(messageId: Int, body: ByteArray) { when (messageId) { MSG_CHANNEL_OPEN_RESPONSE -> { @@ -823,6 +917,12 @@ class GearslipRunner( const val MSG_VIDEO_FOCUS_REQUEST = 32775 const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776 + // aap_protobuf/service/sensor/... - same "first specific message on this channel type + // starts at 0x8000" numbering as media/input above. + const val MSG_SENSOR_START_REQUEST = 32768 + const val MSG_SENSOR_START_RESPONSE = 32769 + const val MSG_SENSOR_EVENT_INDICATION = 32770 + const val STREAM_MEDIA = 3 const val CODEC_H264_BP = 3 const val VIDEO_FOCUS_PROJECTED = 1 @@ -830,6 +930,14 @@ class GearslipRunner( const val MSG_INPUT_REPORT = 32769 const val ACTION_DOWN = 0 const val ACTION_UP = 1 + + // android.view.KeyEvent codes the head unit re-sends as-is (aap_protobuf's KeyCode.proto + // mirrors Android's own keycode space). + const val KEYCODE_SEARCH = 84 + const val KEYCODE_MEDIA_PLAY_PAUSE = 85 + const val KEYCODE_MEDIA_NEXT = 87 + const val KEYCODE_MEDIA_PREVIOUS = 88 + const val KEYCODE_VOICE_ASSIST = 231 const val ACK_STALL_MS = 2_000L const val AUDIO_TRACE_MESSAGES = 6 const val PERIODIC_KEYFRAME_MS = 5_000L diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index 24c2036..0ef8e53 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -106,29 +106,57 @@ object ServiceDiscovery { ) /** - * The head unit's touchscreen, from InputSourceService (Service field 4): - * InputSourceService { keycodes = 1; repeated TouchScreen touchscreen = 2; ... } + * The head unit's touchscreen and button set, from InputSourceService (Service field 4): + * InputSourceService { repeated int32 keycodes_supported = 1 [packed=true]; + * repeated TouchScreen touchscreen = 2; ... } * TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; } * * Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be - * scaled to the projected video size before injection. + * scaled to the projected video size before injection. [keycodesSupported] is what the + * head unit says it will actually send over the Input channel as key events (steering-wheel + * buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to + * know ahead of time whether a button like voice/PTT is even wired up on this hardware. */ - class InputService(val serviceId: Int, val touchWidth: Int, val touchHeight: Int) + class InputService( + val serviceId: Int, + val touchWidth: Int, + val touchHeight: Int, + val keycodesSupported: List = emptyList(), + ) fun findInputService(response: ByteArray): InputService? { for (channel in Wire.allBytes(Wire.fields(response), 1)) { val service = Wire.fields(channel) val id = Wire.varint(service, 1)?.toInt() ?: continue val input = Wire.bytes(service, 4) ?: continue - val touch = Wire.bytes(Wire.fields(input), 2) ?: continue + val inputFields = Wire.fields(input) + val touch = Wire.bytes(inputFields, 2) ?: continue val touchFields = Wire.fields(touch) val width = Wire.varint(touchFields, 1)?.toInt() ?: continue val height = Wire.varint(touchFields, 2)?.toInt() ?: continue - return InputService(id, width, height) + val keycodes = Wire.bytes(inputFields, 1)?.let(::packedVarints).orEmpty() + return InputService(id, width, height, keycodes) } return null } + /** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */ + private fun packedVarints(data: ByteArray): List = buildList { + var pos = 0 + while (pos < data.size) { + var result = 0L + var shift = 0 + while (pos < data.size) { + val b = data[pos].toInt() and 0xFF + result = result or ((b and 0x7F).toLong() shl shift) + pos++ + if (b and 0x80 == 0) break + shift += 7 + } + add(result.toInt()) + } + } + fun findVideoService(response: ByteArray): VideoService? { for (channel in Wire.allBytes(Wire.fields(response), 1)) { val service = Wire.fields(channel) @@ -155,6 +183,31 @@ object ServiceDiscovery { return null } + /** + * The head unit's sensor feed, from SensorSourceService (Service field 5): + * SensorSourceService { repeated SensorType sensors = 1; } + * SensorType { required int32 type = 1; } + * + * Field numbers for what actually rides inside each SensorEvent once subscribed are not + * pinned down here - unlike video/audio/input, there's no independent source to check them + * against, so [CarSensors] shows the raw decode instead of trusting a guessed field map. + */ + class SensorService(val serviceId: Int, val types: List) + + fun findSensorService(response: ByteArray): SensorService? { + for (channel in Wire.allBytes(Wire.fields(response), 1)) { + val service = Wire.fields(channel) + val id = Wire.varint(service, 1)?.toInt() ?: continue + val sensorSource = Wire.bytes(service, 5) ?: continue + val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull { + Wire.varint(Wire.fields(it), 1)?.toInt() + } + if (types.isEmpty()) continue + return SensorService(id, types) + } + return null + } + /** * Who the head unit says it is, from the top-level strings of ServiceDiscoveryResponse. * Field numbers as seen from a 2018 Dodge Challenger: 2="Uconnect", 3="Dodge Challenger", diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantCommands.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantCommands.kt new file mode 100644 index 0000000..12cd633 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantCommands.kt @@ -0,0 +1,182 @@ +package app.seb3thehacker.gearslip.car + +import android.content.Context +import java.text.DateFormat +import java.util.Date +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Turns what [CarAssistant] heard into an action plus something to say back. + * + * Deliberately small and keyword-matched rather than run through any NLU or bundled model: a + * fixed set of things a driver actually wants hands-free (media, calling, opening an app, + * getting back Home), covering the same ground the rest of the car UI already does rather than + * trying to be a general assistant. + */ +object AssistantCommands { + + suspend fun handle(context: Context, navigator: CarNavigator, frame: CarEnvironment.Frame, heardRaw: String): String { + val heard = heardRaw.lowercase().trim() + + navigateCommand(context, navigator, frame, heard)?.let { return it } + mediaCommand(heard)?.let { return it } + navCommand(navigator, heard)?.let { return it } + infoCommand(heard)?.let { return it } + callCommand(context, heard)?.let { return it } + openAppCommand(context, navigator, frame, heard)?.let { return it } + + return "I don't know how to do that yet." + } + + /** + * "navigate to X [with|using|on|via Y]" - Y picks the nav app by (partial) label; without it, + * whichever nav app was used last, or the only one installed, or the first one found. Starts + * it straight into navigating there via [CarAppConnection]'s `ACTION_NAVIGATE` deep link - + * the same one Android Auto's own Assistant integration uses - rather than opening the app + * and leaving the driver to type the address in on the car keyboard. + */ + private suspend fun navigateCommand( + context: Context, + navigator: CarNavigator, + frame: CarEnvironment.Frame, + heard: String, + ): String? { + val trigger = NAVIGATE_TRIGGERS.firstOrNull { heard.startsWith(it) } ?: return null + var destination = heard.removePrefix(trigger).trim() + if (destination.isBlank()) return "Where to?" + + var appName: String? = null + for (sep in listOf(" with ", " using ", " on ", " via ")) { + val at = destination.lastIndexOf(sep) + if (at > 0) { + appName = destination.substring(at + sep.length).trim() + destination = destination.substring(0, at).trim() + break + } + } + if (destination.isBlank()) return "Where to?" + + val entries = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) } + val navApps = entries.filter { it.template?.isNavigation == true } + if (navApps.isEmpty()) return "You don't have any navigation apps installed." + + val requestedApp = appName + val chosen = when { + requestedApp != null -> navApps.firstOrNull { it.label.lowercase().contains(requestedApp) } + ?: return "I couldn't find a navigation app called $requestedApp." + navApps.size == 1 -> navApps.first() + else -> navApps.firstOrNull { it.componentId == CarSettings.lastNav.value } ?: navApps.first() + } + val template = chosen.template ?: return "That's not a navigation app." + + CarServices.connectNav(template, frame, destination) + navigator.home() + return "Navigating to $destination with ${chosen.label}." + } + + private fun mediaCommand(heard: String): String? { + val media = CarServices.media + return when { + "pause" in heard || heard.startsWith("stop") -> { + if (media.now.value.playing) media.togglePlay() + "Paused." + } + "resume" in heard || (heard.startsWith("play") && "playing" !in heard) -> { + if (!media.now.value.playing) media.togglePlay() + "Playing." + } + "next" in heard || "skip" in heard -> { + media.next() + "Skipping." + } + "previous" in heard || "last song" in heard || "back song" in heard -> { + media.previous() + "Playing the previous track." + } + "what" in heard && "playing" in heard -> { + val now = media.now.value + if (now.isActive) listOf(now.title, now.artist).filter { it.isNotBlank() }.joinToString(" by ") + else "Nothing's playing." + } + else -> null + } + } + + private fun navCommand(navigator: CarNavigator, heard: String): String? = when { + "go home" in heard || heard == "home" -> { + navigator.home() + "Heading home." + } + "open apps" in heard || heard == "apps" || "app list" in heard -> { + navigator.apps() + "Here are your apps." + } + "settings" in heard -> { + navigator.settings() + "Opening settings." + } + "vehicle data" in heard || "car data" in heard -> { + if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value) { + navigator.vehicleData() + "Opening vehicle data." + } else { + "Vehicle data is off. Turn it on under experimental features in settings." + } + } + else -> null + } + + private fun infoCommand(heard: String): String? = when { + "what time" in heard || "what's the time" in heard -> + "It's ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date())}." + "weather" in heard -> { + val data = (Weather.state.value as? WeatherState.Ready)?.data + if (data != null) "${data.temp}${data.tempUnit} and ${Weather.describe(data.code)} in ${data.place}." + else "I don't have the weather yet." + } + else -> null + } + + private suspend fun callCommand(context: Context, heard: String): String? { + val name = when { + heard.startsWith("call ") -> heard.removePrefix("call ") + heard.startsWith("phone ") -> heard.removePrefix("phone ") + heard.startsWith("dial ") -> heard.removePrefix("dial ") + else -> return null + }.trim() + if (name.isBlank()) return "Who should I call?" + if (!hasCallPermissions(context)) return "Gearslip needs the phone and contacts permissions first, from the phone screen." + + val match = withContext(Dispatchers.IO) { + loadContacts(context).firstOrNull { it.name.lowercase().contains(name) } + } ?: return "I couldn't find $name in your contacts." + + placeCall(context, match.number) + return "Calling ${match.name}." + } + + private suspend fun openAppCommand( + context: Context, + navigator: CarNavigator, + frame: CarEnvironment.Frame, + heard: String, + ): String? { + val name = when { + heard.startsWith("open ") -> heard.removePrefix("open ") + heard.startsWith("launch ") -> heard.removePrefix("launch ") + heard.startsWith("start ") -> heard.removePrefix("start ") + else -> return null + }.trim() + if (name.isBlank()) return null + + val entries = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) } + val match = entries.firstOrNull { it.label.lowercase().contains(name) } + ?: return "I couldn't find an app called $name." + + launchEntry(match, navigator, frame) + return "Opening ${match.label}." + } + + private val NAVIGATE_TRIGGERS = listOf("navigate to ", "directions to ", "take me to ", "drive to ") +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt new file mode 100644 index 0000000..56c3eba --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt @@ -0,0 +1,143 @@ +package app.seb3thehacker.gearslip.car + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * The band that appears while [CarAssistant] is listening, thinking or speaking - a thin strip + * across the top, same idea as [CallOverlay]'s active-call banner, so it never blocks the screen + * the driver was already looking at. + */ +@Composable +fun AssistantOverlay(modifier: Modifier = Modifier) { + val state by CarAssistant.state.collectAsState() + if (state.phase == CarAssistant.Phase.IDLE) return + val context = LocalContext.current + + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = modifier.fillMaxWidth(), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + PulsingMic(state.phase == CarAssistant.Phase.LISTENING) + Column(Modifier.weight(1f)) { + Text( + when (state.phase) { + CarAssistant.Phase.LISTENING -> "Listening…" + CarAssistant.Phase.THINKING -> "…" + CarAssistant.Phase.SPEAKING -> state.reply + CarAssistant.Phase.ERROR -> state.reply + CarAssistant.Phase.IDLE -> "" + }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + ) + if (state.heard.isNotBlank() && state.phase != CarAssistant.Phase.SPEAKING) { + Text( + "\"${state.heard}\"", + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (state.missingPermission) { + FilledTonalButton(onClick = { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)), + ) + }) { Text("Grant") } + } + IconButton(onClick = CarAssistant::cancel) { + Icon(Icons.Filled.Close, contentDescription = "Dismiss") + } + } + } +} + +/** A filled circle with a mic glyph, pulsing gently while actually listening. */ +@Composable +private fun PulsingMic(listening: Boolean, modifier: Modifier = Modifier) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = modifier.size(36.dp), + ) { + Row(horizontalArrangement = Arrangement.Center) { + MicGlyph(Modifier.padding(8.dp).size(20.dp)) + } + } + // `listening` only changes what's drawn elsewhere in the row (the "Listening…" label); + // the glyph itself doesn't need to animate for this to read clearly on a glance. +} + +/** + * A mic capsule on a small stand, drawn rather than a vector asset - the trimmed core icon set + * this project ships (see [ChargingGlyph]'s doc comment) has no microphone. + */ +@Composable +fun MicGlyph(modifier: Modifier = Modifier) { + val tint = LocalContentColor.current + Canvas(modifier) { + val w = size.width + val h = size.height + val capsuleWidth = w * 0.42f + val capsuleHeight = h * 0.58f + drawRoundRect( + color = tint, + topLeft = Offset((w - capsuleWidth) / 2f, 0f), + size = Size(capsuleWidth, capsuleHeight), + cornerRadius = CornerRadius(capsuleWidth / 2f), + ) + val standStroke = h * 0.09f + val standTop = capsuleHeight * 0.85f + val standBottom = h * 0.82f + drawArc( + color = tint, + startAngle = 20f, + sweepAngle = 140f, + useCenter = false, + topLeft = Offset(w * 0.14f, standTop - h * 0.06f), + size = Size(w * 0.72f, h * 0.5f), + style = Stroke(width = standStroke), + ) + drawLine(tint, Offset(w / 2f, standBottom - h * 0.05f), Offset(w / 2f, standBottom), strokeWidth = standStroke) + drawLine(tint, Offset(w * 0.30f, standBottom), Offset(w * 0.70f, standBottom), strokeWidth = standStroke) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt index dd58342..af4a949 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -60,7 +60,6 @@ fun CarAppStage( AppSurface(connection, frame, template, Modifier.fillMaxSize()) } TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR)) - if (embedded) CornerMask(EMBEDDED_RADIUS, MaterialTheme.colorScheme.background) if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) } } @@ -163,29 +162,5 @@ private fun ChromeBar( } } -/** Corner radius of the home screen's map pane and player. */ -val EMBEDDED_RADIUS = 18.dp - -/** - * Rounds the corners of a SurfaceView, which Compose cannot clip: the app draws into its own - * layer, so the corners are covered from above with the surrounding colour instead. - */ -@Composable -private fun CornerMask(radius: androidx.compose.ui.unit.Dp, color: Color) { - androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { - val bounds = androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height) - val cut = androidx.compose.ui.graphics.Path().apply { - addRect(bounds) - addRoundRect( - androidx.compose.ui.geometry.RoundRect( - bounds, androidx.compose.ui.geometry.CornerRadius(radius.toPx()), - ), - ) - fillType = androidx.compose.ui.graphics.PathFillType.EvenOdd - } - drawPath(cut, color) - } -} - /** Height of [ChromeBar]; the template's own chrome starts below it. */ private val CHROME_BAR = 44.dp diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAssistant.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAssistant.kt new file mode 100644 index 0000000..a4e1fe9 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAssistant.kt @@ -0,0 +1,188 @@ +package app.seb3thehacker.gearslip.car + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * A voice assistant built entirely on Android's own speech APIs - [SpeechRecognizer] to listen, + * [TextToSpeech] to answer - rather than a bundled recognition engine (see the conversation this + * was scoped from: a project like Dicio ships its own STT model and isn't built to be embedded + * as a library, which cuts against how the rest of Gearslip is built). Both APIs just talk to + * whatever the system has configured, so this works unchanged on GrapheneOS against GrapheneOS + * Speech Services, or on stock Android against whatever the phone shipped with - Gearslip never + * names an engine. + * + * Listening and speaking live here; deciding what a command *means* is [AssistantCommands], kept + * separate because it needs a [CarNavigator] and a frame to act on, neither of which this object + * has - see [pendingCommand]. + */ +object CarAssistant { + + enum class Phase { IDLE, LISTENING, THINKING, SPEAKING, ERROR } + + data class State( + val phase: Phase = Phase.IDLE, + val heard: String = "", + val reply: String = "", + val missingPermission: Boolean = false, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state + + /** Set once a recognition result comes in; [reply] clears it once handled. */ + private val _pendingCommand = MutableStateFlow(null) + val pendingCommand: StateFlow = _pendingCommand + + private var appContext: Context? = null + private var recognizer: SpeechRecognizer? = null + private var tts: TextToSpeech? = null + private var ttsReady = false + + fun init(context: Context) { + appContext = context.applicationContext + if (tts != null) return + tts = TextToSpeech(appContext) { status -> ttsReady = status == TextToSpeech.SUCCESS } + } + + private val mainHandler = Handler(Looper.getMainLooper()) + + /** + * Starts listening, or does nothing if the assistant is already busy with the last request. + * [SpeechRecognizer] must be driven from a thread with a Looper, but this is also called from + * the raw wire-protocol worker thread ([GearslipRunner]'s key-event handling for the head + * unit's own PTT button) which has none - so this always hops to the main thread itself + * rather than asking every caller to remember to. + */ + fun start() { + if (Looper.myLooper() == Looper.getMainLooper()) startOnMainThread() else mainHandler.post(::startOnMainThread) + } + + private fun startOnMainThread() { + val context = appContext ?: return + if (_state.value.phase == Phase.LISTENING || _state.value.phase == Phase.THINKING) return + + if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.RECORD_AUDIO) != + PackageManager.PERMISSION_GRANTED + ) { + _state.value = State(Phase.ERROR, reply = "Needs the microphone permission.", missingPermission = true) + return + } + if (!SpeechRecognizer.isRecognitionAvailable(context)) { + _state.value = State(Phase.ERROR, reply = "No speech recognizer is set up on this phone.") + return + } + + tts?.stop() + _state.value = State(Phase.LISTENING) + + val r = SpeechRecognizer.createSpeechRecognizer(context) + recognizer = r + r.setRecognitionListener(object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) {} + override fun onBeginningOfSpeech() {} + override fun onRmsChanged(rmsdB: Float) {} + override fun onBufferReceived(buffer: ByteArray?) {} + + override fun onEndOfSpeech() { + _state.value = _state.value.copy(phase = Phase.THINKING) + } + + override fun onError(error: Int) { + releaseRecognizer() + _state.value = State(Phase.ERROR, reply = errorMessage(error)) + } + + override fun onResults(results: Bundle?) { + releaseRecognizer() + val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull().orEmpty() + if (text.isBlank()) { + _state.value = State(Phase.ERROR, reply = "Didn't catch that.") + } else { + _state.value = _state.value.copy(phase = Phase.THINKING, heard = text) + _pendingCommand.value = text + } + } + + override fun onPartialResults(partialResults: Bundle?) { + val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull() + if (text != null) _state.value = _state.value.copy(heard = text) + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + }) + r.startListening( + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) + }, + ) + } + + /** Closes the overlay from wherever it's showing: mid-listen, mid-think, or after speaking. */ + fun cancel() { + releaseRecognizer() + tts?.stop() + _pendingCommand.value = null + _state.value = State(Phase.IDLE) + } + + /** + * Called once [pendingCommand] has been turned into an answer - from [CarUi], the one place + * that actually has a [CarNavigator] to have acted on it with. + */ + fun reply(text: String) { + _pendingCommand.value = null + _state.value = _state.value.copy(phase = Phase.SPEAKING, reply = text) + + val engine = tts + if (engine == null || !ttsReady || text.isBlank()) { + _state.value = _state.value.copy(phase = Phase.IDLE) + return + } + engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) {} + override fun onDone(utteranceId: String?) { + _state.value = _state.value.copy(phase = Phase.IDLE) + } + @Deprecated("Deprecated in Java, no replacement on this API level") + override fun onError(utteranceId: String?) { + _state.value = _state.value.copy(phase = Phase.IDLE) + } + }) + engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant") + } + + private fun releaseRecognizer() { + recognizer?.let { runCatching { it.cancel() }; it.destroy() } + recognizer = null + } + + private fun errorMessage(code: Int): String = when (code) { + SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Didn't catch that." + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Needs the microphone permission." + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "The speech recognizer is busy." + SpeechRecognizer.ERROR_NETWORK, SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "The speech recognizer needs a network it doesn't have." + else -> "Speech recognition failed." + } + + fun shutdown() { + releaseRecognizer() + tts?.shutdown() + tts = null + ttsReady = false + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt index 860c366..165923e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -25,8 +25,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -54,18 +52,22 @@ fun CarHome() { LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() } val navigator = LocalCarNavigator.current - val showLyrics = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen + val splitScreen = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen - Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // No outer padding on any side, split screen or not - only the gap between the two panes + // when lyrics are sharing the screen with the map. + Row( + Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { Box( Modifier - .weight(if (showLyrics) 2.1f else 1f) - .fillMaxHeight() - .clip(RoundedCornerShape(EMBEDDED_RADIUS)), + .weight(if (splitScreen) 2.1f else 1f) + .fillMaxHeight(), ) { MapPane(navStatus, frame) } - if (showLyrics) { + if (splitScreen) { LyricsColumn(now, Modifier.weight(1f).fillMaxHeight()) } } @@ -85,6 +87,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame horizontalAlignment = Alignment.CenterHorizontally, ) { val hasLast = CarSettings.lastNav.value != null + val hasNavApps by CarServices.hasNavApps.collectAsStateWithLifecycle() val scope = androidx.compose.runtime.rememberCoroutineScope() when (status.phase) { CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> @@ -94,24 +97,25 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } TextButton(onClick = navigator::apps) { Text("Choose another app") } } - else -> { + else -> if (!hasNavApps) { + // Nothing to auto-select and nothing to pick from Car apps either - this is the + // only case worth a distinct screen, since every other IDLE case resolves itself. + Text("No mapping apps found.", style = MaterialTheme.typography.titleMedium) + Text( + "Install a navigation app that supports Android Auto and it will start here on its own.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (hasLast) { // IDLE with a last app on record means the driver stopped it themselves - it will // not come back on its own, so the screen must offer a way to bring it back rather // than sit on a "starting" message that never resolves. - Text( - if (hasLast) "Map stopped." else "No map app chosen yet.", - style = MaterialTheme.typography.titleMedium, - ) - if (hasLast) { - TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") } - } else { - Text( - "Open Car apps and connect a navigation app; it will start here next time.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text("Map stopped.", style = MaterialTheme.typography.titleMedium) + TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") } TextButton(onClick = { navigator.apps() }) { Text("Car apps") } + } else { + // Autostart is still picking one - a navigation app is installed, so this resolves + // to a "Starting..." state itself, in the next frame or two. } } } @@ -126,7 +130,6 @@ private fun LyricsColumn(now: NowPlaying, modifier: Modifier) { val navigator = LocalCarNavigator.current Surface( modifier = modifier, - shape = RoundedCornerShape(EMBEDDED_RADIUS), color = MaterialTheme.colorScheme.surfaceVariant, ) { Column(Modifier.fillMaxSize().padding(16.dp)) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt index 31e5956..3da3f0c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt @@ -8,12 +8,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -26,6 +28,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.RoundedCornerShape +/** + * Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the + * bottom nav bar normally holds instead of squeezing it in above - a driver typing has no use + * for Home/Apps/Settings shortcuts anyway, and the screen is small enough that the space matters. + */ +object CarKeyboardVisibility { + val state = mutableStateOf(false) +} + /** * A keyboard the host draws itself. * @@ -35,7 +46,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape * the same reasons, so this does too - large keys, no long-press, no prediction. * * It is a pure function of [text]: every key reports the new string and holds no state beyond - * which layer is showing. + * which layer is showing. Laid out like a PC's QWERTY keyboard rather than a phone's: shift and + * backspace flank the letter block (top-right and bottom-left of it, same as a physical board), + * not the space bar, so a fumbled reach for space never lands on delete instead. + * + * [onDismiss] is null wherever there's no keyboard-shown state independent of the screen itself + * (a search template, sign-in) - there, the header's own back action is the only way out and no + * dismiss button is drawn. */ @Composable fun CarKeyboard( @@ -45,10 +62,16 @@ fun CarKeyboard( modifier: Modifier = Modifier, submitIcon: Boolean = true, submitImage: ImageVector? = null, + onDismiss: (() -> Unit)? = null, ) { var symbols by remember { mutableStateOf(false) } var shifted by remember { mutableStateOf(false) } + DisposableEffect(Unit) { + CarKeyboardVisibility.state.value = true + onDispose { CarKeyboardVisibility.state.value = false } + } + val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS Surface( @@ -59,7 +82,7 @@ fun CarKeyboard( Modifier.padding(horizontal = 6.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - rows.forEach { row -> + rows.dropLast(1).forEach { row -> Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -74,19 +97,40 @@ fun CarKeyboard( } } + // The bottom letter row: shift and backspace bookend it, exactly where a physical + // keyboard puts them relative to the letter block, not down beside the space bar. + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (!symbols) { + Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted } + } + rows.last().forEach { key -> + val label = if (shifted && !symbols) key.uppercase() else key + Key(label, Modifier.weight(1f)) { + onTextChange(text + label) + shifted = false + } + } + Key("⌫", Modifier.weight(1.6f)) { + if (text.isNotEmpty()) onTextChange(text.dropLast(1)) + } + } + + // Control row: no backspace here - it stays with the letters above, not next to the + // key most likely to be hit right after it by mistake. Dismiss (when offered) is the + // last key, bottom-right corner of the keyboard. Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols } - if (!symbols) { - Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.4f)) { shifted = !shifted } - } - Key("space", Modifier.weight(3f)) { onTextChange("$text ") } - Key("⌫", Modifier.weight(1.4f)) { - if (text.isNotEmpty()) onTextChange(text.dropLast(1)) - } + Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") } SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit) + if (onDismiss != null) { + DismissKey(onDismiss, Modifier.weight(1.4f)) + } } } } @@ -115,6 +159,24 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni } } +@Composable +private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + shape = RoundedCornerShape(8.dp), + modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick), + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Close, contentDescription = "Hide keyboard") + } + } +} + @Composable private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) { Surface( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt index 8351939..4a5877c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -50,6 +51,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Warning import app.seb3thehacker.gearslip.host.KnownApps import app.seb3thehacker.gearslip.host.CarAppCatalog import app.seb3thehacker.gearslip.host.TemplateApp @@ -67,6 +69,8 @@ private class Tile( val verified: Boolean = false, /** Known not to work; drawn with a red X instead of being left off the launcher. */ val broken: Boolean = false, + /** Starts and can be used, but rough enough not to call working; drawn with a yellow mark. */ + val partial: Boolean = false, val pinned: Boolean = false, /** Null for the built-in tiles (Web, Screen sharing, Settings) - nothing to pin them as. */ val onLongClick: (() -> Unit)? = null, @@ -100,8 +104,19 @@ internal object LauncherCache { context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap() }.getOrNull() - val nav = CarAppCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), template = it) } - val media = MediaCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), media = it) } + val navApps = CarAppCatalog.installed(context) + val mediaApps = MediaCatalog.installed(context) + // Some apps (Spotify among them) ship both a Car App Library template and a legacy + // MediaBrowserService, which otherwise land as two identically-labeled tiles with no + // way to tell them apart. They're genuinely different screens - browsing versus Now + // Playing - so only the label needs disambiguating, not the tile itself. + val mediaPackages = mediaApps.map { it.component.packageName }.toSet() + + val nav = navApps.map { + val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label + Entry(label, iconOf(it.component.packageName), template = it) + } + val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) } return (nav + media).sortedBy { it.label.lowercase() }.also { entries = it } } } @@ -115,7 +130,15 @@ fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = tru * kind of app. */ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) { - entry.template?.let { CarServices.connectNav(it, frame); navigator.home() } + entry.template?.let { + if (it.isNavigation) { + CarServices.connectNav(it, frame) + navigator.home() + } else { + CarServices.connectBrowse(it, frame) + navigator.browse(entry.label) + } + } entry.media?.let { CarServices.openMedia(it); navigator.media() } } @@ -132,18 +155,27 @@ fun CarLauncher() { } val pinned by CarSettings.pinnedApps.collectAsState() + val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() + val carSensorsEnabled by CarSettings.carSensorsEnabled.collectAsState() - val tiles = listOf( - Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") }, - Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") }, - Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") }, - ) + installed.map { entry -> + val leadingTiles = buildList { + add(Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") }) + add(Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") }) + add(Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") }) + // Experimental: raw, unverified sensor labels - see CarSensors. + if (experimentalFeatures && carSensorsEnabled) { + add(Tile("Vehicle data", glyph = Icons.Filled.Info) { navigator.vehicleData() }) + } + } + + val tiles = leadingTiles + installed.map { entry -> val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName val id = entry.componentId Tile( entry.label, entry.icon, verified = pkg != null && KnownApps.works(pkg), broken = pkg != null && KnownApps.isBroken(pkg), + partial = pkg != null && KnownApps.isPartial(pkg), pinned = id != null && id in pinned, onLongClick = id?.let { { CarSettings.togglePin(it) } }, ) { launchEntry(entry, navigator, frame) } @@ -159,27 +191,38 @@ fun CarLauncher() { verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(tiles) { AppTile(it) } - if (tiles.any { it.verified }) { - item(span = { GridItemSpan(maxLineSpan) }) { VerifiedLegend() } + if (tiles.any { it.verified || it.broken || it.partial }) { + item(span = { GridItemSpan(maxLineSpan) }) { + BadgeLegend( + verified = tiles.any { it.verified }, + partial = tiles.any { it.partial }, + broken = tiles.any { it.broken }, + ) + } } } } -/** Says what the check mark on a tile means, once, under the grid. */ +/** Says what each badge on a tile means, once, under the grid - only the ones actually in use. */ @Composable -private fun VerifiedLegend() { - Row( +private fun BadgeLegend(verified: Boolean, partial: Boolean, broken: Boolean) { + Column( Modifier.fillMaxWidth().padding(top = 4.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - VerifiedBadge(16.dp) + if (verified) LegendRow(size = 16.dp, badge = { VerifiedBadge(it) }, text = "Tested and working with Gearslip") + if (partial) LegendRow(size = 16.dp, badge = { PartialBadge(it) }, text = "Runs, but not cleanly, with Gearslip") + if (broken) LegendRow(size = 16.dp, badge = { BrokenBadge(it) }, text = "Doesn't work with Gearslip yet") + } +} + +@Composable +private fun LegendRow(size: androidx.compose.ui.unit.Dp, badge: @Composable (androidx.compose.ui.unit.Dp) -> Unit, text: String) { + Row(horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + badge(size) Spacer(Modifier.width(8.dp)) - Text( - "Tested and working with Gearslip", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -205,6 +248,17 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = } } +/** A yellow disc with a warning mark: neither the check nor the X, since this app runs but isn't clean about it. */ +@Composable +private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) { + Box( + modifier.size(size).background(Color(0xFFF9A825), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Warning, contentDescription = "Works, but not cleanly, with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.6f)) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun AppTile(tile: Tile) { @@ -247,6 +301,9 @@ private fun AppTile(tile: Tile) { if (tile.broken) { BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp)) } + if (tile.partial) { + PartialBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp)) + } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt index c52fc8d..e3093fd 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt @@ -17,9 +17,19 @@ sealed interface CarScreen { /** Opened from the weather chip or the dashboard: now, the next hours and the week. */ data object Weather : CarScreen + /** Everything the head unit's sensor channel has advertised and sent, raw. */ + data object VehicleData : CarScreen + /** The current media app, full size: browse, lyrics and controls. */ data object Media : CarScreen + /** + * A templated app that isn't a navigation app - Spotify's own Car App Library service, among + * others - opened on its own screen rather than the Home map slot, which only a genuine + * navigation app can claim. + */ + data object Browse : CarScreen + /** [replyTo] is a notification key to open straight into its reply pane. */ data class Notifications(val replyTo: String? = null) : CarScreen data class App(val id: String) : CarScreen @@ -78,6 +88,8 @@ class CarNavigator(startId: String) { fun media() { push(CarScreen.Media, "Media") } + fun browse(label: String) { push(CarScreen.Browse, label) } + fun apps() { push(CarScreen.Apps, "Apps") } fun settings() { push(CarScreen.Settings, "Settings") } @@ -86,6 +98,8 @@ class CarNavigator(startId: String) { fun weather() { push(CarScreen.Weather, "Weather") } + fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") } + fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") } fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSensors.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSensors.kt new file mode 100644 index 0000000..1126dfe --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSensors.kt @@ -0,0 +1,95 @@ +package app.seb3thehacker.gearslip.car + +import app.seb3thehacker.gearslip.Protobuf +import app.seb3thehacker.gearslip.Wire +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Everything the head unit's sensor channel has offered and sent, for the "Vehicle data" screen. + * + * [ServiceDiscovery.findSensorService] tells us which numeric sensor types this head unit is + * willing to stream; [SensorType] names the ones publicly documented by other AA reverse-engineering + * projects (aasdk, openauto). That mapping is carried over from other projects, not verified + * against this exact unit, so it's a label, not a guarantee - the raw field dump next to it is + * the ground truth. Once [onEvent] starts decoding real traffic from the 2018 Uconnect, correct + * any type that turns out to be mislabeled. + */ +object CarSensors { + + data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long) + + private val readingsFlow = MutableStateFlow>(emptyList()) + val readings: StateFlow> = readingsFlow + + /** Seeds one row per sensor type the head unit advertised, so the screen shows what's on + * offer even before the first event for it arrives. */ + fun onDiscovered(types: List) { + readingsFlow.value = types.map { id -> + Reading(id, SensorType.name(id), "(waiting for data)", 0L) + } + } + + /** + * One SensorEventIndication. Every reverse-engineered version of this message multiplexes + * every sensor type into one shared set of message-level field numbers (field N = "the data + * for sensor type N", roughly) rather than tagging events with the type id from + * SensorType.type - so field number is read here as a best-effort type id, same as the + * other services in this file. + */ + fun onEvent(body: ByteArray) { + val now = System.currentTimeMillis() + val fields = Wire.fields(body) + val byType = fields.groupBy { it.number } + val updated = byType.mapValues { (number, group) -> + Reading( + number, + SensorType.name(number), + group.joinToString("; ") { field -> + field.bytes?.let { Protobuf.describe(it, indent = "").trim().replace("\n", ", ") } + ?: "varint=${field.varint}" + }, + now, + ) + } + val existing = readingsFlow.value.associateBy { it.typeId } + readingsFlow.value = (existing + updated).values.sortedBy { it.typeId } + } + + fun clear() { + readingsFlow.value = emptyList() + } +} + +/** + * Sensor type ids as named across public Android Auto reverse-engineering references. Treated as + * a label, not a fact - see [CarSensors] doc. + */ +object SensorType { + private val NAMES = mapOf( + 1 to "Location (GPS)", + 2 to "Compass", + 3 to "Speed", + 4 to "RPM", + 5 to "Odometer", + 6 to "Fuel", + 7 to "Parking brake", + 8 to "Gear", + 9 to "Diagnostics", + 10 to "Night mode", + 11 to "Environment", + 12 to "HVAC", + 13 to "Driving status", + 14 to "Dead reckoned location", + 15 to "Passenger", + 16 to "Door", + 17 to "Light", + 18 to "Tire pressure", + 19 to "Accelerometer", + 20 to "Gyroscope", + 21 to "Gravity", + 22 to "Wheel tick / distance", + ) + + fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id" +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index 178ef9e..aa7e4db 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -2,13 +2,17 @@ package app.seb3thehacker.gearslip.car import android.content.ComponentName import android.content.Context +import android.media.session.MediaSessionManager +import android.media.session.PlaybackState import app.seb3thehacker.gearslip.audio.CarAudio import app.seb3thehacker.gearslip.host.CarAppCatalog import app.seb3thehacker.gearslip.host.CarAppConnection +import app.seb3thehacker.gearslip.host.KnownApps import app.seb3thehacker.gearslip.host.TemplateApp import app.seb3thehacker.gearslip.media.CarMedia import app.seb3thehacker.gearslip.media.MediaApp import app.seb3thehacker.gearslip.media.MediaCatalog +import app.seb3thehacker.gearslip.notify.GearslipNotificationListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -29,9 +33,23 @@ object CarServices { val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } + /** + * A second, independent connection for templated apps that aren't navigation apps - Spotify's + * own Car App Library service is the first of these. Kept apart from [nav] so opening one + * can never bump the map off Home: this one gets its own screen ([CarScreen.Browse]) instead. + */ + val browse: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } + private val _mediaApp = MutableStateFlow(null) val mediaApp: StateFlow = _mediaApp.asStateFlow() + /** + * Whether the phone has any navigation app installed at all. True until [autostart] has + * actually checked, so the home screen never flashes "none found" before it knows. + */ + private val _hasNavApps = MutableStateFlow(true) + val hasNavApps: StateFlow = _hasNavApps.asStateFlow() + /** Set when the driver stops the map by hand, so going Home doesn't immediately restart it. */ private var navStopped = false private var autoplayPending = false @@ -40,14 +58,25 @@ object CarServices { context = appContext.applicationContext app.seb3thehacker.gearslip.notify.CarMotion.start(appContext) app.seb3thehacker.gearslip.call.CarCalls.start(appContext) + CarAssistant.init(appContext) } // --- map ----------------------------------------------------------------------------- - fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame) { + /** + * [destination] is a search query to start navigating to right away - "navigate to X with Y" + * from the voice assistant - left null for a plain app launch. An app that's already the one + * running gets the destination sent to its live session instead of being torn down and + * rebuilt, same as [CarAppConnection.navigateTo] documents. + */ + fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame, destination: String? = null) { navStopped = false CarSettings.setLastNav(app.component.flattenToString()) - nav.connect(app, frame.width, frame.height, frame.densityDpi) + if (destination != null && nav.connectedComponent == app.component) { + nav.navigateTo(destination) + } else { + nav.connect(app, frame.width, frame.height, frame.densityDpi, destination) + } } fun stopNav() { @@ -61,6 +90,17 @@ object CarServices { autostart(frame) } + // --- browse (non-navigation templated apps) ------------------------------------------- + + /** Opens a templated app that isn't navigation, on its own screen - never on Home. */ + fun connectBrowse(app: TemplateApp, frame: CarEnvironment.Frame) { + browse.connect(app, frame.width, frame.height, frame.densityDpi) + } + + fun stopBrowse() { + browse.disconnect() + } + // --- media --------------------------------------------------------------------------- fun openMedia(app: MediaApp, autoplay: Boolean = false) { @@ -91,30 +131,65 @@ object CarServices { val ctx = context ?: return if (!navStopped && nav.status.value.phase == CarAppConnection.Phase.IDLE) { + val navApps = withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) } + .filter { it.isNavigation } + _hasNavApps.value = navApps.isNotEmpty() + // Never auto-pick one already known to fail outright - a driver who plugs in should + // land on a map, not on the one tile marked with a red X. + val candidates = navApps.filterNot { KnownApps.isBroken(it.component.packageName) } val last = CarSettings.lastNav.value - val app = last?.let { id -> - withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) } - .firstOrNull { it.component == ComponentName.unflattenFromString(id) } - } + // The one used last, or - first run, nothing on record yet - whichever comes first: + // a driver who plugs in for the first time shouldn't have to go pick one by hand + // before the map shows up at all. + val lastComponent = last?.let(ComponentName::unflattenFromString) + val app = candidates.firstOrNull { it.component == lastComponent } ?: candidates.firstOrNull() if (app != null) connectNav(app, frame) } if (_mediaApp.value == null) { - val last = CarSettings.lastMedia.value - val app = last?.let { id -> - withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) } - .firstOrNull { it.component == ComponentName.unflattenFromString(id) } + val catalog = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) } + // Something already playing on the phone wins over whatever was last used in the car - + // a driver who started a song before plugging in shouldn't have to go open its tile by + // hand just to see what's already going. + val playing = withContext(Dispatchers.IO) { currentlyPlaying(ctx, catalog) } + if (playing != null) { + openMedia(playing) + } else { + val last = CarSettings.lastMedia.value + val app = last?.let { id -> catalog.firstOrNull { it.component == ComponentName.unflattenFromString(id) } } + if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value) } - if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value) } } + /** + * Whichever known car media app has an actively playing session right now, system-wide - not + * just the one Gearslip itself last connected to. Reading another app's session needs + * notification access, the same permission [app.seb3thehacker.gearslip.media.CarMedia] + * already asks for to control apps that won't share their library. + */ + private fun currentlyPlaying(ctx: Context, catalog: List): MediaApp? { + val manager = ctx.getSystemService(MediaSessionManager::class.java) + val listener = ComponentName(ctx, GearslipNotificationListener::class.java) + val sessions = try { + manager.getActiveSessions(listener) + } catch (e: SecurityException) { + return null + } + val playingPackages = sessions + .filter { it.playbackState?.state == PlaybackState.STATE_PLAYING } + .map { it.packageName } + return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } } + } + /** The car went away: nothing should keep running for a screen that no longer exists. */ fun shutdown() { context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) } app.seb3thehacker.gearslip.call.CarCalls.stop() runCatching { nav.disconnect() } runCatching { media.disconnect() } + runCatching { browse.disconnect() } + CarAssistant.cancel() CarAudio.release() _mediaApp.value = null navStopped = false diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index e2e628e..b4aeb80 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -25,6 +25,10 @@ object CarSettings { private const val KEY_AUTOPLAY = "car_autoplay" private const val KEY_THEME = "car_app_theme" private const val KEY_PINNED = "car_pinned_apps" + private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features" + private const val KEY_VOICE_ASSISTANT = "car_voice_assistant" + private const val KEY_CAR_SENSORS = "car_sensors_enabled" + private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning" /** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to * still be on the bar, not off the edge of a long list. */ @@ -45,6 +49,10 @@ object CarSettings { private val themeFlow = MutableStateFlow(AppTheme.PHONE) val appTheme: StateFlow = themeFlow private val pinnedFlow = MutableStateFlow>(emptySet()) + private val experimentalFeaturesFlow = MutableStateFlow(false) + private val voiceAssistantFlow = MutableStateFlow(false) + private val carSensorsFlow = MutableStateFlow(false) + private val seenSafetyWarningFlow = MutableStateFlow(false) /** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */ val pinnedApps: StateFlow> = pinnedFlow @@ -68,6 +76,40 @@ object CarSettings { */ val pipeAudio: StateFlow = pipeAudioFlow + /** + * Off by default: a driver has to opt into experimental features as a whole before any of + * them - the voice assistant included - show up to turn on individually. + */ + val experimentalFeaturesEnabled: StateFlow = experimentalFeaturesFlow + + /** + * The voice assistant is experimental and off by default; a driver has to turn it on + * knowingly, from the settings screen, after reading the warning shown there. This flag + * alone does not run the assistant - [experimentalFeaturesEnabled] must also be on. + */ + val voiceAssistantEnabled: StateFlow = voiceAssistantFlow + + /** + * Vehicle data is experimental and off by default: the launcher's tile for it, and the + * voice assistant's "vehicle data" command, only show up once this and + * [experimentalFeaturesEnabled] are both on. See [app.seb3thehacker.gearslip.car.CarSensors] + * for why the raw sensor feed it shows is worth gating - the field labels are unverified. + */ + val carSensorsEnabled: StateFlow = carSensorsFlow + + /** + * Whether the car screen's own "drive safely" warning has already been shown and dismissed + * once, from this same install. Separate from the phone side's own compat/safety dialogs - + * a driver who only ever plugs in and never opens the phone app should still see this once, + * on the screen they are actually about to use while driving. + */ + val hasSeenSafetyWarning: StateFlow = seenSafetyWarningFlow + + fun markSafetyWarningSeen() { + seenSafetyWarningFlow.value = true + AppSettings.putString(app, KEY_SEEN_SAFETY_WARNING, "true") + } + fun init(context: Context) { app = context.applicationContext scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f @@ -84,6 +126,10 @@ object CarSettings { }.getOrDefault(NightMode.AUTO) pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "") .split(",").filter { it.isNotEmpty() }.toSet() + experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true" + voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true" + carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" + seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true" } fun setScale(value: Float) { @@ -121,6 +167,21 @@ object CarSettings { AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString()) } + fun setExperimentalFeaturesEnabled(on: Boolean) { + experimentalFeaturesFlow.value = on + AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString()) + } + + fun setVoiceAssistantEnabled(on: Boolean) { + voiceAssistantFlow.value = on + AppSettings.putString(app, KEY_VOICE_ASSISTANT, on.toString()) + } + + fun setCarSensorsEnabled(on: Boolean) { + carSensorsFlow.value = on + AppSettings.putString(app, KEY_CAR_SENSORS, on.toString()) + } + fun setNightMode(mode: NightMode) { nightFlow.value = mode AppSettings.putString(app, KEY_NIGHT, mode.name) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index 9198632..f7be5e1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -9,11 +9,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -46,6 +48,9 @@ fun CarSettingsScreen() { val pipeAudio by CarSettings.pipeAudio.collectAsState() val autoplay by CarSettings.autoplay.collectAsState() val theme by CarSettings.appTheme.collectAsState() + val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() + val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState() + val carSensors by CarSettings.carSensorsEnabled.collectAsState() val display by CarEnvironment.display.collectAsState() val vehicle by CarEnvironment.vehicle.collectAsState() val context = LocalContext.current @@ -110,6 +115,15 @@ fun CarSettingsScreen() { ScreenFit(vehicle) + ChoiceRow( + "Experimental features", + listOf(false to "Hide", true to "Show"), + experimentalFeatures, + ) { CarSettings.setExperimentalFeaturesEnabled(it) } + if (experimentalFeatures) { + ExperimentalFeaturesList(voiceAssistant, carSensors) + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}") @@ -145,6 +159,72 @@ private fun ChoiceRow(title: String, options: List>, selecte } } +/** + * Individually-switched experimental features, shown once the driver has opted into + * experimental features as a whole. Each is its own row - a title, what it does, and a switch - + * so the list reads fine whether there's one entry or several. + */ +@Composable +private fun ExperimentalFeaturesList(voiceAssistant: Boolean, carSensors: Boolean) { + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + ExperimentalFeatureRow( + title = "Voice assistant", + description = "Push-to-talk and the car's voice button start listening: navigate, " + + "play media, call, or open an app by saying so. Can mishear you or act on the " + + "wrong command - keep your eyes on the road, and confirm anything it does " + + "before you rely on it.", + warning = true, + checked = voiceAssistant, + ) { CarSettings.setVoiceAssistantEnabled(it) } + + ExperimentalFeatureRow( + title = "Vehicle data", + description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " + + "channel. Field labels (speed, RPM, fuel...) are carried over from other " + + "reverse-engineering projects and unverified against this car - treat them as a " + + "guess, not a fact, until you've watched one change for yourself.", + checked = carSensors, + ) { CarSettings.setCarSensorsEnabled(it) } + } +} + +@Composable +private fun ExperimentalFeatureRow( + title: String, + description: String, + checked: Boolean, + warning: Boolean = false, + onCheckedChange: (Boolean) -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + Spacer(Modifier.width(12.dp)) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } + } +} + /** The URL (or raw HTML) the Web app shows. Edited with [CarKeyboard] - no phone in hand needed. */ @Composable private fun WebPageSetting() { @@ -182,6 +262,7 @@ private fun WebPageSetting() { AppSettings.setStartupUrl(context, it) }, onSubmit = { editing = false }, + onDismiss = { editing = false }, ) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index d5b1901..7104e66 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -21,9 +21,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.background import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -50,7 +50,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.media.CarMedia import app.seb3thehacker.gearslip.media.MediaArt import androidx.compose.ui.platform.LocalContext @@ -58,7 +60,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp -import app.seb3thehacker.gearslip.host.CarAppConnection import app.seb3thehacker.gearslip.notify.CarNotifications import androidx.core.graphics.drawable.toBitmap import kotlinx.coroutines.Dispatchers @@ -102,6 +103,18 @@ fun CarUi() { // The map follows the light outside, whatever the app's own theme is set to. LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() } + // The one place with a navigator and a frame to act a voice command on - CarAssistant itself + // only knows how to listen and speak, not what "open Spotify" means. + val frameForAssistant by CarEnvironment.frame.collectAsState() + LaunchedEffect(navigator) { + CarAssistant.pendingCommand.collect { heard -> + if (heard != null) { + val reply = AssistantCommands.handle(appContext, navigator, frameForAssistant, heard) + CarAssistant.reply(reply) + } + } + } + CompositionLocalProvider( LocalDensity provides Density(base.density * scale, base.fontScale), LocalCarNavigator provides navigator, @@ -131,7 +144,12 @@ fun CarUi() { ) } val screen = navigator.current + val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState() Column(Modifier.fillMaxSize().padding(pad)) { + if (!safetyWarningSeen) { + CarSafetyWizard(onDone = CarSettings::markSafetyWarningSeen) + return@Column + } // Header navigation (breadcrumb trail) - off for now, kept to tweak later. // BreadcrumbBar(navigator) Box(Modifier.weight(1f).fillMaxWidth()) { @@ -139,9 +157,11 @@ fun CarUi() { CarScreen.Home -> CarHome() CarScreen.Apps -> CarLauncher() CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } } + CarScreen.Browse -> BrowseAppScreen(navigator) CarScreen.Settings -> CarSettingsScreen() CarScreen.Dashboard -> CarDashboardScreen() CarScreen.Weather -> WeatherScreen() + CarScreen.VehicleData -> VehicleDataScreen() is CarScreen.Notifications -> NotificationsScreen(screen.replyTo) is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke() } @@ -151,14 +171,102 @@ fun CarUi() { } // Above the popup: a call is more urgent than any notification. CallOverlay(Modifier.align(Alignment.TopCenter)) + AssistantOverlay(Modifier.align(Alignment.TopCenter)) + if (app.seb3thehacker.gearslip.BuildConfig.DEBUG) { + // Bottom corner, not top: a screen's own header almost always puts + // something in the top-right (settings, search, a Liked Songs + // shortcut) and this badge used to sit right on top of it, hiding + // whatever the screen drew there. + DevBuildBadge(Modifier.align(Alignment.BottomEnd)) + } } - CarNavBar(navigator) + // The keyboard takes the nav bar's row instead of squeezing in above it - + // nothing on the nav bar is useful mid-type, and the screen is too small to + // show both. + val keyboardVisible by CarKeyboardVisibility.state + if (!keyboardVisible) CarNavBar(navigator) } } } } } +/** + * A single blocking screen, shown once per install before anything else the car screen can show - + * Home included. The phone's own setup guide carries the same warning, but a driver who plugs in + * without ever opening the phone app would otherwise never see it, on the one screen they are + * actually about to use while driving. [onDone] fires once, from its own button; there is no way + * to skip past this without tapping it. + */ +@Composable +private fun CarSafetyWizard(onDone: () -> Unit) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = MaterialTheme.shapes.extraLarge, + ) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Drive safely", style = ChromeType.headline, fontWeight = FontWeight.Bold) + Text( + "Gearslip is unofficial software: it is not made or reviewed by any " + + "carmaker, and it can fail without warning.", + style = ChromeType.body, + ) + Text( + "Never interact with this screen while driving. Set the route or the " + + "song before you go, then keep your attention on the road.", + style = ChromeType.body, + ) + } + } + Spacer(Modifier.height(24.dp)) + Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text("I understand", style = ChromeType.body) + } + } + } +} + +/** + * A templated app that isn't navigation, full size with its own title bar and Stop button - the + * same [CarAppStage] chrome the map uses on Home, just not embedded there. [CarServices.browse] + * is a connection of its own, so opening one of these never bumps the map off Home. + */ +@Composable +private fun BrowseAppScreen(navigator: CarNavigator) { + val status by CarServices.browse.status.collectAsState() + val frame by CarEnvironment.frame.collectAsState() + CarAppStage(CarServices.browse, status, frame) { + CarServices.stopBrowse() + navigator.back() + } +} + +/** + * Marks a debug build on the car screen itself, not just the phone's Home screen - the one + * that matters while driving, since a debug build is slower and less tested than what ships. + */ +@Composable +private fun DevBuildBadge(modifier: Modifier = Modifier) { + Surface( + modifier = modifier.padding(6.dp), + color = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + shape = MaterialTheme.shapes.small, + ) { + Text( + "DEV BUILD", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + /** * "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to * show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one @@ -216,12 +324,6 @@ private fun CarNavBar(navigator: CarNavigator) { val weather by Weather.state.collectAsState() val weatherData = (weather as? WeatherState.Ready)?.data - // The one app that's actually open and not otherwise reachable from this bar: media has its - // own pill already, so the only "open app" icon worth showing is whichever map is connected. - val navStatus by CarServices.nav.status.collectAsState() - val lastNav by CarSettings.lastNav.collectAsState() - val openMapComponent = lastNav?.takeIf { navStatus.phase == CarAppConnection.Phase.RUNNING } - val context = LocalContext.current val pinnedIds by CarSettings.pinnedApps.collectAsState() val entries by produceState(LauncherCache.entries ?: emptyList(), context) { @@ -239,14 +341,16 @@ private fun CarNavBar(navigator: CarNavigator) { // breadcrumb trail is how you retrace your steps, but reaching for either of // these two from three screens deep in a media app shouldn't mean reading it // first. - NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() } + NavItem(ImageVector.vectorResource(R.drawable.navigation_24), navigator.current == CarScreen.Home) { navigator.home() } Spacer(Modifier.width(6.dp)) NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() } - - openMapComponent?.let { component -> + val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() + val voiceEnabled by CarSettings.voiceAssistantEnabled.collectAsState() + if (experimentalFeatures && voiceEnabled) { Spacer(Modifier.width(6.dp)) - NavAppIcon(component, contentDescription = "Map", onClick = navigator::home) + NavAssistantIcon() } + pinnedEntries.forEach { entry -> Spacer(Modifier.width(6.dp)) entry.componentId?.let { id -> @@ -334,6 +438,28 @@ private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable ) { content() } } +/** + * Between Apps and whatever's actually open, not with the clock/battery group on the right - a + * driver reaches for it right after deciding "not a tap for this", same gesture family as the + * two buttons beside it. + */ +@Composable +private fun NavAssistantIcon() { + val state by CarAssistant.state.collectAsState() + val scheme = MaterialTheme.colorScheme + val active = state.phase != CarAssistant.Phase.IDLE + Box( + Modifier + .clip(RoundedCornerShape(18.dp)) + .background(if (active) scheme.secondaryContainer else scheme.surfaceContainer) + .clickable { CarAssistant.start() } + .padding(12.dp), + contentAlignment = Alignment.Center, + ) { + MicGlyph(Modifier.size(28.dp)) + } +} + @Composable private fun NavItem(icon: ImageVector, selected: Boolean, padding: androidx.compose.ui.unit.Dp = 12.dp, onClick: () -> Unit) { val scheme = MaterialTheme.colorScheme diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index 8ef39fb..7bf3350 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -87,8 +87,11 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { // The connection outlives this screen: the home screen's player is the same one. LaunchedEffect(app) { CarServices.openMedia(app) } - Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.fillMaxSize()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { IconButton(onClick = onExit) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back to apps") } Text(app.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Spacer(Modifier.weight(1f)) @@ -117,7 +120,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { } CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null) CarMedia.Phase.READY -> Row( - Modifier.fillMaxSize().padding(top = 6.dp), + Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { // The art rail sits at the top, level with the tab row beside it, on every tab - @@ -172,9 +175,10 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli */ @Composable internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) { + val context = LocalContext.current val lyrics by produceState(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) { value = LyricsState.Loading - value = Lyrics.find(now.title, now.artist, now.album, now.durationMs) + value = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs) ?.let { LyricsState.Found(it) } ?: LyricsState.Missing } val list = rememberLazyListState() @@ -273,7 +277,7 @@ private fun formatDuration(ms: Long): String { * for [NowPlayingExtras], since those are the one thing that isn't already always shown here. */ @Composable -private fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) { +internal fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) { val context = LocalContext.current val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) @@ -293,7 +297,7 @@ private fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) BoxWithConstraints(modifier) { // Off the space actually available rather than a fixed dp value, so it doesn't overrun a // short car frame the way a flat size did on anything shorter than a phone screen. - val artSize = minOf(maxWidth, maxHeight * 0.45f).coerceAtLeast(96.dp) + val artSize = (minOf(maxWidth, maxHeight * 0.45f) * 1.2f).coerceAtLeast(96.dp) Column(Modifier.verticalScroll(rememberScrollState())) { Surface( shape = MaterialTheme.shapes.large, @@ -441,7 +445,7 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) { modifier = Modifier.fillMaxWidth(), ) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) { + Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) { icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } } Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { @@ -468,7 +472,7 @@ private fun EntryRow(entry: MediaEntry, media: CarMedia) { modifier = Modifier.fillMaxWidth(), ) { Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) { + Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) { icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } } Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { @@ -492,6 +496,7 @@ internal object MediaIcons { /** The 3x3 app grid; not in material-icons-core. */ val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z") val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z") + val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z") private fun icon(name: String, path: String) = ImageVector.Builder( name, 24.dp, 24.dp, 24f, 24f, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt index 7395c2a..f01a27e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt @@ -220,7 +220,10 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) { Text(n.text.ifBlank { n.title }, style = MaterialTheme.typography.bodyLarge, maxLines = 3, overflow = TextOverflow.Ellipsis) } - CarKeyboard(text, onTextChange = { text = it }, onSubmit = ::send, submitImage = Icons.AutoMirrored.Filled.Send) + CarKeyboard( + text, onTextChange = { text = it }, onSubmit = ::send, + submitImage = Icons.AutoMirrored.Filled.Send, onDismiss = onDone, + ) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt index eb3d510..eda9481 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt @@ -50,14 +50,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** A phone contact with at least one number, as read off the device's contacts. */ -private data class Contact(val name: String, val number: String) +internal data class Contact(val name: String, val number: String) private val CALL_PERMISSIONS = arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_CONTACTS) -private fun hasCallPermissions(context: Context): Boolean = +/** Also used by [AssistantCommands] for "call ". */ +internal fun hasCallPermissions(context: Context): Boolean = CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED } -private fun loadContacts(context: Context): List { +internal fun loadContacts(context: Context): List { if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList() val out = mutableListOf() val projection = arrayOf( @@ -80,7 +81,7 @@ private fun loadContacts(context: Context): List { return out.distinctBy { it.number } } -private fun placeCall(context: Context, number: String) { +internal fun placeCall(context: Context, number: String) { if (number.isBlank()) return if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return runCatching { @@ -266,7 +267,12 @@ private fun ContactsList(context: Context) { } } if (searching) { - CarKeyboard(query, onTextChange = { query = it }, onSubmit = { searching = false }) + CarKeyboard( + query, + onTextChange = { query = it }, + onSubmit = { searching = false }, + onDismiss = { searching = false }, + ) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt index 10a38cc..591270d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt @@ -1,9 +1,11 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -20,6 +23,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.car.app.model.GridItem import androidx.car.app.model.GridTemplate import androidx.car.app.model.Item import androidx.car.app.model.ItemList @@ -67,7 +72,13 @@ import app.seb3thehacker.gearslip.host.textChanged * the same function either way - the only difference is how much room they get. */ @Composable -fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStartPane: Boolean = false) { +fun ContentTemplate( + template: Template?, + modifier: Modifier = Modifier, + autoStartPane: Boolean = false, + ownActionsElsewhere: Boolean = false, + tabId: String = "", +) { when (template) { null -> Unit is ListTemplate -> ListContent(template, modifier) @@ -78,7 +89,7 @@ fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStar is SearchTemplate -> SearchContent(template, modifier) is SignInTemplate -> SignInContent(template, modifier) is TabTemplate -> TabContent(template, modifier) - is SectionedItemTemplate -> SectionedContent(template, modifier) + is SectionedItemTemplate -> SectionedContent(template, modifier, showActionsInHeader = !ownActionsElsewhere, tabId = tabId) is MediaPlaybackTemplate -> MediaPlaybackContent(template, modifier) else -> UnsupportedChrome(template, modifier) } @@ -374,43 +385,65 @@ private fun Callout(value: String, modifier: Modifier) { @Composable private fun TabContent(template: TabTemplate, modifier: Modifier) { val active = template.activeTabContentId - ContentSurface(modifier) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp) - .verticalScrollNone(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - template.headerAction?.let { ActionButton(it) } - template.tabs.forEach { tab -> - val selected = tab.contentId == active - Surface( - color = if (selected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(20.dp), - modifier = Modifier.clickable { - template.tabCallbackDelegate.tabSelected(tab.contentId) - }, - ) { - Row( - Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + val innerTemplate = template.tabContents?.template + // A sectioned tab's own actions (search, a Liked Songs shortcut) belong on the same line as + // the tabs themselves - a real Android Auto host's header row, not a second near-empty row + // underneath it that SectionedContent would otherwise draw just to hold them. + val trailingActions = (innerTemplate as? SectionedItemTemplate)?.actions.orEmpty().filter { it.isDrawable() } + Box(modifier) { + ContentSurface(Modifier.fillMaxSize()) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp) + .verticalScrollNone(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + template.headerAction?.let { ActionButton(it) } + template.tabs.forEach { tab -> + val selected = tab.contentId == active + Surface( + color = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.clickable { + template.tabCallbackDelegate.tabSelected(tab.contentId) + }, ) { - tab.icon?.let { - CarGlyph(it, Modifier.size(20.dp)) - Spacer(Modifier.width(6.dp)) + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + tab.icon?.let { + CarGlyph(it, Modifier.size(20.dp)) + Spacer(Modifier.width(6.dp)) + } + Text(tab.title.text(), style = ChromeType.label, maxLines = 1) } - Text(tab.title.text(), style = ChromeType.label, maxLines = 1) } } } + if (template.isLoading) Loading() + else ContentTemplate( + innerTemplate, + Modifier.weight(1f), + ownActionsElsewhere = trailingActions.isNotEmpty(), + tabId = active.orEmpty(), + ) + } + // Bottom-left, not on the tab row: a driver's hand rests near the bottom of the screen, + // and the tab row is already busy with the tabs themselves. + if (trailingActions.isNotEmpty()) { + Row( + Modifier.align(Alignment.BottomStart).padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + trailingActions.forEach { ActionButton(it) } + } } - if (template.isLoading) Loading() - else ContentTemplate(template.tabContents?.template, Modifier.weight(1f)) } } @@ -419,46 +452,66 @@ private fun Modifier.verticalScrollNone(): Modifier = this // --- sectioned items -------------------------------------------------------------------------- +/** + * A section's items ([SectionRows]) are cached for the life of the process, keyed off the + * foreground app and, when nested under a tab, the tab - so flipping back to a shelf already + * seen this drive is instant instead of re-asking the app over the binder. Bounded and in + * memory only, so it costs nothing on disk and disappears with the process. + */ +private val sectionCache = android.util.LruCache>(64) + /** * Sections fetch their items across the binder on demand rather than carrying them, so each one - * is asked for its full range once and the answer is held while the section is on screen. + * is asked for its full range once and the answer is held while the section is on screen (and, + * via [sectionCache], for the rest of the drive). */ @Composable -private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier) { +private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier, showActionsInHeader: Boolean = true, tabId: String = "") { + val appPackage = CarServices.nav.appPackage.orEmpty() ContentSurface(modifier) { + // The app's own actions (Spotify's search and Liked Songs shortcut, for two) come from + // template.actions, not the header - but a bottom-of-content row is wrong for a driver + // reaching for it. Normally the header's own end-actions slot is where a real Android + // Auto host puts these; nested under a TabTemplate, though, TabContent already drew them + // on the tab row itself, so showActionsInHeader is false and this header stays title-only + // (and usually collapses to nothing, since a tab's own content carries no separate title). HeaderBar( headerTitle(template.header, ""), template.header?.startHeaderAction, - template.header?.endHeaderActions.orEmpty(), + template.header?.endHeaderActions.orEmpty() + if (showActionsInHeader) template.actions.orEmpty() else emptyList(), ) if (template.isLoading) { Loading() return@ContentSurface } Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - template.sections.forEach { section -> + template.sections.forEachIndexed { index, section -> PaneTitle(section.title.text()) - SectionRows(section) + SectionRows(section, "$appPackage/$tabId/$index/${section.title.text()}") } } - ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp)) } } @Composable -private fun SectionRows(section: Section<*>) { - var items by remember(section) { mutableStateOf>(emptyList()) } +private fun SectionRows(section: Section<*>, cacheKey: String) { + var items by remember(section) { mutableStateOf(sectionCache.get(cacheKey).orEmpty()) } LaunchedEffect(section) { + if (sectionCache.get(cacheKey) != null) return@LaunchedEffect val delegate = section.itemsDelegate - val size = runCatching { delegate.size }.getOrDefault(0) + val sizeResult = runCatching { delegate.size } + val size = sizeResult.getOrDefault(0) + sizeResult.onFailure { GearslipLog.w("host: section.itemsDelegate.size threw: ${it.javaClass.simpleName}: ${it.message}") } if (size <= 0) return@LaunchedEffect runCatching { delegate.requestItemRange(0, size - 1, object : OnDoneCallback { override fun onSuccess(response: Bundleable?) { val value = response?.let { runCatching { it.get() }.getOrNull() } @Suppress("UNCHECKED_CAST") - items = (value as? List).orEmpty() + val fetched = (value as? List).orEmpty() + items = fetched + sectionCache.put(cacheKey, fetched) } override fun onFailure(response: Bundleable) = @@ -472,31 +525,59 @@ private fun SectionRows(section: Section<*>) { if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body) return } - items.filterIsInstance().forEach { RowItem(it) } + // A section's items are homogeneous in practice - one kind or the other - but nothing in + // the API guarantees that, so both are handled rather than assuming whichever the app + // happened to send first. + val rows = items.filterIsInstance() + val tiles = items.filterIsInstance() + rows.forEach { RowItem(it) } + if (tiles.isNotEmpty()) { + // A plain scrollable Row, not LazyRow: the car screen renders into a VirtualDisplay + // Presentation rather than a normal window, and LazyRow's SubcomposeLayout measures to + // zero height there even with an explicit size - a handful of tiles per shelf makes the + // lost virtualization no real loss. + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + tiles.forEach { tile -> GridTile(tile, Modifier.width(180.dp)) } + } + } } // --- media ---------------------------------------------------------------------------------- /** - * Media playback is drawn from the app's MediaSession rather than from the template, which - * carries only a header. Gearslip has no media channel yet, so this says so plainly instead of - * showing an empty player. + * The template itself carries only a header - real Android Auto hosts draw this screen from the + * app's MediaSession instead, which is exactly what [CarServices.media] already does for the + * media-app launcher and the bottom bar. Reusing [NowPlayingRail] here rather than a "not + * supported" placeholder means an app whose own now-playing template Gearslip lands on (like + * Spotify's "Liked Songs" screen) still gets working transport controls. */ @Composable private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modifier) { + val media = CarServices.media + val now by media.now.collectAsState() ContentSurface(modifier) { HeaderBar( headerTitle(template.header, ""), template.header?.startHeaderAction, template.header?.endHeaderActions.orEmpty(), ) - Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { - Text( - "Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.", - style = ChromeType.body, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - ) + if (now.title.isNotEmpty()) { + NowPlayingRail(now, media, Modifier.weight(1f).fillMaxWidth()) + } else { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + "Nothing playing yet.", + style = ChromeType.body, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index d178758..bdf9da1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -1,9 +1,13 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.material3.LocalContentColor import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.foundation.Image +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -424,9 +428,16 @@ private fun standardFor(action: Action): Int? = when (action.type) { Action.TYPE_BACK -> CarIcon.TYPE_BACK Action.TYPE_PAN -> CarIcon.TYPE_PAN Action.TYPE_APP_ICON -> CarIcon.TYPE_APP_ICON + ACTION_TYPE_NOW_PLAYING -> ACTION_TYPE_NOW_PLAYING else -> null } +/** + * The media "now playing" standard action, newer than the Car App Library we build against, so it + * has no named constant here. Spotify puts it in its header with no icon of its own. + */ +private const val ACTION_TYPE_NOW_PLAYING = 65542 + // --- icons ---------------------------------------------------------------------------------- /** @@ -437,7 +448,14 @@ private fun standardFor(action: Action): Int? = when (action.type) { @Composable internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: Int? = null) { val context = LocalContext.current - val bitmap = icon.image(context) + // Off the main thread and cached per icon: loadDrawable() is a Binder round trip into the + // app's own process for a content:// icon, plus a bitmap decode - done synchronously on the + // composition thread for every tile on screen, that's what made a shelf of ten covers feel + // like it was hanging rather than loading. + val bitmapState by produceState(null, icon) { + value = withContext(Dispatchers.IO) { icon.image(context) } + } + val bitmap = bitmapState if (bitmap != null) { // An icon that names a tint (DEFAULT included) expects the host to colour it for the // surface it sits on; without this a white glyph vanishes on a light theme. @@ -458,6 +476,7 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I CarIcon.TYPE_ALERT, CarIcon.TYPE_ERROR -> Icons.Filled.Warning // No four-way pan glyph in material-icons-core; this is the closest honest stand-in. CarIcon.TYPE_PAN -> Icons.Filled.Menu + ACTION_TYPE_NOW_PLAYING -> MediaIcons.Equalizer else -> null } ?: return Icon(vector, contentDescription = null, modifier = modifier) @@ -500,7 +519,9 @@ internal fun ItemListColumn( } } -/** A grid of large tappable tiles - what apps use for a home screen beside the map. */ +/** A grid of tappable tiles - what apps use for a home screen beside the map. Icons here are as + * often a small menu glyph (Organic Maps' Search, say) as real cover art, so the tile stays + * modest; [GridTile]'s own default is for the shelf case, where every tile is a photo. */ @Composable internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) { val items = list?.items?.filterIsInstance().orEmpty() @@ -512,32 +533,41 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - items(items) { item -> - Column( - Modifier - .clickable { item.onClickDelegate.click("grid item") } - .padding(vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CarGlyph(item.image, Modifier.size(36.dp)) - Spacer(Modifier.height(6.dp)) - Text( - item.title.text(), - style = ChromeType.label, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - val subtitle = item.text.text() - if (subtitle.isNotEmpty()) { - Text( - subtitle, - style = ChromeType.label, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + items(items) { item -> GridTile(item, iconSize = 36.dp) } + } +} + +/** + * One cover-art tile: an app's own icon over a title and optional subtitle. Shared by + * [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph) + * and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways + * instead of wrapping into a grid and wants its full-size cover art. + */ +@Composable +internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) { + Column( + modifier + .clickable { item.onClickDelegate.click("grid item") } + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CarGlyph(item.image, Modifier.size(iconSize)) + Spacer(Modifier.height(6.dp)) + Text( + item.title.text(), + style = ChromeType.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = item.text.text() + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = ChromeType.label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } @@ -631,6 +661,7 @@ internal fun Color.luminanceIsDark(): Boolean = (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f + /** True for a glyph drawn entirely in white (any transparency): a mask, not a picture. */ private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean { val pixels = IntArray(width * height) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/VehicleDataScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/VehicleDataScreen.kt new file mode 100644 index 0000000..a05cce0 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/VehicleDataScreen.kt @@ -0,0 +1,69 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * Everything the head unit's sensor channel is willing to send, live and unfiltered - see + * [CarSensors] for why the labels are a best guess and the raw field dump next to each one is + * the part to actually trust while confirming what this unit really sends. + */ +@Composable +fun VehicleDataScreen() { + val readings by CarSensors.readings.collectAsState() + + Column(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) { + Text("Vehicle data", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text( + "Raw sensor channel from the connected head unit. Field meaning is a best guess - " + + "watch a row change while you do the thing (brake, drive, headlights) to confirm it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), + ) + + if (readings.isEmpty()) { + Text( + "No sensor service advertised by this head unit, or not connected.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Column + } + + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(readings, key = { it.typeId }) { reading -> ReadingRow(reading) } + } + } +} + +@Composable +private fun ReadingRow(reading: CarSensors.Reading) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(reading.typeName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } + Text( + reading.raw, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt index abeb867..9169e55 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt @@ -86,6 +86,10 @@ fun WebApp(content: String) { web?.evaluateJavascript(setFieldValueJs(updated), null) }, onSubmit = { web?.evaluateJavascript(SUBMIT_FIELD_JS, null) }, + onDismiss = { + fieldText = null + web?.evaluateJavascript(BLUR_FIELD_JS, null) + }, ) } } @@ -144,6 +148,10 @@ private fun setFieldValueJs(value: String) = """ // Fires a real Enter keydown first - the same event a site's own JS would see from a physical // keyboard - and only falls back to submitting the form directly if nothing handled it, so a // search-as-you-type page and a plain
both work without submitting twice. +// Dismissing the host keyboard without blurring the page's own field would leave it focused, +// so the next tap anywhere with a text cursor pops the keyboard straight back up. +private const val BLUR_FIELD_JS = "document.activeElement && document.activeElement.blur();" + private const val SUBMIT_FIELD_JS = """ (function() { var el = document.activeElement; diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt index 03cfcc2..9217903 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -8,6 +8,7 @@ import android.content.ServiceConnection import android.content.res.Configuration import android.graphics.Rect import android.location.Location +import android.net.Uri import android.os.Handler import android.os.IBinder import android.os.Looper @@ -94,18 +95,25 @@ class CarAppConnection(private val context: Context) { /** Whose icon the header shows for the standard app-icon action. */ val appPackage: String? get() = component?.packageName + /** Which app is bound right now, if any - lets a caller tell "still this one" from "a new one". */ + val connectedComponent: ComponentName? get() = component + private var frameWidth = 800 private var frameHeight = 480 private var frameDensity = 160 + /** A destination to navigate to as soon as the app is created; see [navigateTo]. */ + private var pendingDestination: String? = null + // --- connection --------------------------------------------------------------------- - fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int) { + fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int, destination: String? = null) { disconnect() frameWidth = width frameHeight = height frameDensity = densityDpi component = app.component + pendingDestination = destination update { Status(phase = Phase.BINDING, app = app.label) } val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component) @@ -196,6 +204,7 @@ class CarAppConnection(private val context: Context) { _panMode.value = false surfaceCallback = null component = null + pendingDestination = null _template.value = null update { Status() } } @@ -242,12 +251,34 @@ class CarAppConnection(private val context: Context) { private fun onHandshakeAccepted() { val app = carApp ?: return GearslipLog.i("host: handshake accepted; creating the app") - val intent = Intent(Intent.ACTION_MAIN).setComponent(component) + val intent = pendingDestination?.let { navigateIntent(it) } + ?: Intent(Intent.ACTION_MAIN).setComponent(component) + pendingDestination = null call("onAppCreate", onValue = { onCreated() }) { app.onAppCreate(carHost, intent, carConfiguration(), it) } } + /** + * "navigate to X" deep link, the same one Android Auto's own Assistant integration sends - + * [ACTION_NAVIGATE] with the destination as a `geo:` search query, rather than anything + * app-specific. + */ + private fun navigateIntent(destination: String) = + Intent(CarContext.ACTION_NAVIGATE, Uri.parse("geo:0,0?q=" + Uri.encode(destination))) + .setComponent(component) + + /** + * Sends a fresh destination to an app that's already running, instead of tearing it down + * and rebuilding the whole session - see [app.seb3thehacker.gearslip.car.CarServices.connectNav]. + */ + fun navigateTo(destination: String) { + val app = carApp ?: return + call("onNewIntent", onValue = {}) { + app.onNewIntent(navigateIntent(destination), it) + } + } + private fun onCreated() { val app = carApp ?: return GearslipLog.i("host: created; starting") diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt index 4acf1b3..8ab1a90 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt @@ -32,6 +32,16 @@ import java.util.concurrent.TimeUnit /** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */ fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty() +/** + * Icons keep coming back under fresh [CarIcon]/IconCompat objects - every template refetch + * deserializes its own copies over the binder - so caching by object identity would never hit. + * [describe] is stable across those copies for the same underlying icon (same URI or resource), + * which is what makes it usable as a cache key. Capacity is in entries, not bytes: cover art at + * car-screen size is small and a shelf plus its sections stays well under a hundred distinct + * icons on screen at once. + */ +private val iconCache = android.util.LruCache(200) + /** * Loads an icon the app described. * @@ -41,10 +51,38 @@ fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.to */ fun CarIcon?.image(context: Context): ImageBitmap? { val icon = this?.icon ?: return null - val drawable: Drawable = runCatching { icon.loadDrawable(context) }.getOrNull() ?: return null - val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX - val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX - return runCatching { drawable.toBitmap(width, height).asImageBitmap() }.getOrNull() + val key = icon.describe() + iconCache.get(key)?.let { return it } + val drawable: Drawable = runCatching { icon.loadDrawable(context) } + .onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") } + .getOrNull() + ?: run { + GearslipLog.w("icon: loadDrawable returned null for ${icon.describe()}") + return null + } + val rawWidth = drawable.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX + val rawHeight = drawable.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX + // A handful of apps (Spotify's search glyph, 12x12) hand over an icon so small it turns to + // mush once Compose stretches it up to button size. Rasterizing it larger here instead lets + // Android's own bitmap scaler do that smoothing while the source is still a Drawable, rather + // than Compose scaling an already-tiny texture at draw time. + val scale = (ICON_FALLBACK_PX.toFloat() / maxOf(rawWidth, rawHeight)).coerceAtLeast(1f) + val width = (rawWidth * scale).toInt() + val height = (rawHeight * scale).toInt() + val bitmap = runCatching { drawable.toBitmap(width, height).asImageBitmap() } + .onFailure { GearslipLog.w("icon: toBitmap threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") } + .getOrNull() ?: return null + iconCache.put(key, bitmap) + return bitmap +} + +/** Enough of an [androidx.core.graphics.drawable.IconCompat] to tell log lines apart without dumping the whole object. */ +private fun androidx.core.graphics.drawable.IconCompat.describe(): String = when (type) { + androidx.core.graphics.drawable.IconCompat.TYPE_URI, + androidx.core.graphics.drawable.IconCompat.TYPE_URI_ADAPTIVE_BITMAP, + -> "uri=$uri" + androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE -> "resource=$resPackage:$resId" + else -> "type=$type" } /** Standard icons carry no bitmap - the host draws its own, so callers need to know which. */ diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt index b1cd4a2..c03c079 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt @@ -35,7 +35,16 @@ object KnownApps { private val broken = setOf( "com.google.android.apps.messaging", // Google Messages "com.waze", // Waze + "com.generalmagic.magicearth", // Magic Earth: never gets past connecting ) fun isBroken(packageName: String): Boolean = packageName in broken + + /** Shown on the launcher with a yellow mark: starts and can be used, but not cleanly enough to call working. */ + private val partial = setOf( + "app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet + "com.spotify.music", // Spotify: Now Playing tile works, browse tile rejects Gearslip as a host + ) + + fun isPartial(packageName: String): Boolean = packageName in partial } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt index b86f0ab..e532e45 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.IBinder +import app.seb3thehacker.gearslip.GearslipLog /** * Lets a hosted navigation app keep receiving the phone's location while Gearslip is not on screen. @@ -34,7 +35,16 @@ class LocationKeepAlive : Service() { .setContentTitle("Gearslip is sharing location with the car map") .setOngoing(true) .build() - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + // The system can refuse a location-type foreground service if Gearslip is no longer in + // an eligible state by the time this runs - the phone screen locking mid-session is the + // common case. That refusal throws, and left uncaught it takes the whole app down with + // it; a nav app losing its background location fix is a small loss next to that. + runCatching { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + }.onFailure { + GearslipLog.w("location keep-alive: could not start the foreground service - ${it.message}") + stopSelf() + } return START_NOT_STICKY } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt index d52dcc5..38b3b3d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt @@ -1,13 +1,16 @@ package app.seb3thehacker.gearslip.media +import android.content.Context import app.seb3thehacker.gearslip.GearslipLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject +import java.io.File import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder +import java.security.MessageDigest class LyricLine(val timeMs: Long, val text: String) @@ -20,24 +23,67 @@ class LyricsResult(val lines: List, val synced: Boolean) * Media apps almost never publish lyrics in their session metadata, so this is the only general * source. It sends the track's title, artist, album and length to lrclib.net, which is why the * lookup happens only when the driver opens the lyrics view and never in the background. + * + * Results are cached twice over: in memory for the life of the process, and on disk under the + * cache directory so a song looked up on a past drive doesn't need the network again on this one. + * A miss (no lyrics found) is cached in memory only - not on disk - so a transient lookup failure + * doesn't harden into a permanent "no lyrics" the next time the same track plays. */ object Lyrics { private val cache = HashMap() - suspend fun find(title: String, artist: String, album: String, durationMs: Long): LyricsResult? { + suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? { if (title.isBlank()) return null val key = "$title|$artist|$album|$durationMs" synchronized(cache) { if (cache.containsKey(key)) return cache[key] } + + val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) } + if (fromDisk != null) { + synchronized(cache) { cache[key] = fromDisk } + return fromDisk + } + val result = withContext(Dispatchers.IO) { runCatching { exact(title, artist, album, durationMs) ?: search(title, artist) }.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull() } synchronized(cache) { cache[key] = result } + if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result) } return result } + private fun cacheFile(context: Context, key: String): File { + val digest = MessageDigest.getInstance("SHA-1").digest(key.toByteArray()) + val name = digest.joinToString("") { "%02x".format(it) } + return File(context.cacheDir, "lyrics").apply { mkdirs() }.resolve("$name.json") + } + + private fun readDisk(context: Context, key: String): LyricsResult? = runCatching { + val file = cacheFile(context, key) + if (!file.exists()) return null + val json = JSONObject(file.readText()) + val lines = json.getJSONArray("lines").let { arr -> + (0 until arr.length()).map { i -> + val line = arr.getJSONObject(i) + LyricLine(line.getLong("t"), line.getString("x")) + } + } + LyricsResult(lines, json.getBoolean("synced")) + }.getOrNull() + + private fun writeDisk(context: Context, key: String, result: LyricsResult) { + runCatching { + val lines = JSONArray() + result.lines.forEach { line -> + lines.put(JSONObject().put("t", line.timeMs).put("x", line.text)) + } + val json = JSONObject().put("synced", result.synced).put("lines", lines) + cacheFile(context, key).writeText(json.toString()) + }.onFailure { GearslipLog.w("lyrics: could not write disk cache: ${it.message}") } + } + private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? { if (artist.isBlank()) return null val query = buildString { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt index d29decf..7f63a6a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -1,7 +1,18 @@ package app.seb3thehacker.gearslip.ui import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -10,8 +21,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import app.seb3thehacker.gearslip.AppSettings +import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.CertProvider private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP } @@ -35,32 +52,70 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { } BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME } - when (screen) { - Screen.HOME -> HomeScreen( - onOpenLogs = { screen = Screen.LOGS }, - onOpenSettings = { screen = Screen.SETTINGS }, - onOpenCarPreview = { screen = Screen.CAR_PREVIEW }, - onOpenHelp = { screen = Screen.HELP }, - onDisconnect = onDisconnect, - ) - Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME }) - Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME }) - Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME }) - Screen.HELP -> HelpScreen(onBack = { screen = Screen.HOME }, onRequestCallScreening = onRequestCallScreening) - Screen.CERT_SETUP -> CertSetupScreen( - onDone = { - screen = if (AppSettings.hasSeenPermissionsSetup(context)) Screen.HOME else Screen.SETUP_GUIDE - }, - ) - Screen.SETUP_GUIDE -> SetupGuideScreen( - onRequestCallScreening = onRequestCallScreening, - onDone = { screen = Screen.HOME }, - ) + Column( + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + .consumeWindowInsets(WindowInsets.statusBars), + ) { + if (BuildConfig.DEBUG) DevBuildBanner() + + Column(Modifier.weight(1f)) { + when (screen) { + Screen.HOME -> HomeScreen( + onOpenLogs = { screen = Screen.LOGS }, + onOpenSettings = { screen = Screen.SETTINGS }, + onOpenCarPreview = { screen = Screen.CAR_PREVIEW }, + onOpenHelp = { screen = Screen.HELP }, + onDisconnect = onDisconnect, + ) + Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME }) + Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME }) + Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME }) + Screen.HELP -> HelpScreen( + onBack = { screen = Screen.HOME }, + onRequestCallScreening = onRequestCallScreening, + onReplayTutorial = { screen = Screen.SETUP_GUIDE }, + ) + Screen.CERT_SETUP -> CertSetupScreen( + onDone = { + screen = if (AppSettings.hasSeenPermissionsSetup(context)) Screen.HOME else Screen.SETUP_GUIDE + }, + ) + Screen.SETUP_GUIDE -> SetupGuideScreen( + onRequestCallScreening = onRequestCallScreening, + onDone = { screen = Screen.HOME }, + ) + } + } } CompatWarning() } +/** + * Debug builds only ([BuildConfig.DEBUG]); a release build never composes this. Sits above every + * phone screen, below the status bar - not the car screen, which is a separate render target + * ([app.seb3thehacker.gearslip.car.CarUi] carries its own corner badge) - so a debug install is + * never mistaken for a release one. + */ +@Composable +private fun DevBuildBanner() { + Surface( + modifier = Modifier.fillMaxWidth().height(28.dp), + color = Color(0xFFF5C518), + contentColor = Color.Black, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "DEV BUILD", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + ) + } + } +} + /** Shown once, on first run: the certificate is known to fail on head units newer than ~2020. */ @Composable private fun CompatWarning() { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt index e76e349..097ae82 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt @@ -1,7 +1,6 @@ package app.seb3thehacker.gearslip.ui import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,15 +11,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -80,11 +82,24 @@ private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf( /** The full setup reference, behind the help button: what the one-time guide walks through, kept * here too so it can be reread without repeating the guide itself. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) { +fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit, onReplayTutorial: () -> Unit) { val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) } - Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = { Text("How to connect") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> Column( Modifier .fillMaxSize() @@ -92,17 +107,11 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) { .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()), ) { - Spacer(Modifier.height(24.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { Text("‹", style = MaterialTheme.typography.headlineMedium) } - Text( - "How to connect", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, - ) + Spacer(Modifier.height(8.dp)) + FilledTonalButton(onClick = onReplayTutorial, modifier = Modifier.fillMaxWidth().height(52.dp)) { + Text("Replay the guided tutorial") } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(24.dp)) Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { steps.forEachIndexed { index, step -> Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { @@ -136,22 +145,3 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) { } } } - -/** The small circular "?" button that opens [HelpScreen] from the top right of the home screen. */ -@Composable -fun HelpButton(onClick: () -> Unit) { - Surface( - onClick = onClick, - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Box(Modifier.padding(10.dp), contentAlignment = Alignment.Center) { - Text( - "?", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 5cbdd41..3b54467 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,27 +13,30 @@ import androidx.compose.foundation.layout.size import android.content.Context import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.SessionStatus @Composable @@ -44,8 +48,6 @@ fun HomeScreen( onDisconnect: () -> Unit, ) { val status by SessionStatus.state.collectAsStateWithLifecycle() - val context = LocalContext.current - var debug by remember { mutableStateOf(AppSettings.debugMode(context)) } Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> Column( @@ -59,76 +61,55 @@ fun HomeScreen( .weight(1f) .verticalScroll(rememberScrollState()), ) { - Spacer(Modifier.height(24.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Column { - Text( - "Gearslip", - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - "v${LocalContext.current.appVersionName()}", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - HelpButton(onClick = onOpenHelp) - } - Spacer(Modifier.height(16.dp)) - StatusCard(status) Spacer(Modifier.height(24.dp)) Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Column(Modifier.weight(1f)) { - Text("Debug mode", style = MaterialTheme.typography.titleMedium) - Text( - if (debug) "Full protocol logging, live logs and car preview." - else "Quiet logging. Warnings and errors are still recorded.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text( + "Gearslip", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + IconButton( + onClick = onOpenSettings, + modifier = Modifier.size(56.dp), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp)) } - Switch(checked = debug, onCheckedChange = { - debug = it - AppSettings.setDebugMode(context, it) - }) } + Text( + "v${LocalContext.current.appVersionName()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + StatusCard(status) Spacer(Modifier.height(16.dp)) } - // Actions sit at the bottom, where a thumb reaches them. - Column( - Modifier.padding(vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + // Sits at the bottom, where a thumb reaches it. + Column(Modifier.padding(vertical = 16.dp)) { val live = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING if (live) { Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { Text("Disconnect") } + Spacer(Modifier.height(16.dp)) } - if (!live && debug) { - FilledTonalButton( - onClick = onOpenCarPreview, - modifier = Modifier.fillMaxWidth().height(56.dp), - ) { - Text("Car preview") - } - } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - if (debug) { - FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) { - Text("Live logs") + + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { + Column { + if (!live) { + ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview) + RowDivider() } - } - OutlinedButton(onClick = onOpenSettings, modifier = Modifier.weight(1f).height(56.dp)) { - Text("Settings") + ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) + RowDivider() + ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp) } } } @@ -136,6 +117,34 @@ fun HomeScreen( } } +@Composable +private fun ActionRow(icon: ImageVector, label: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun RowDivider() { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) +} + @Composable private fun StatusCard(status: SessionStatus.Snapshot) { val scheme = MaterialTheme.colorScheme diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index 950b2ce..24e8fd5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -22,10 +22,12 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.layout.Row import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -37,12 +39,14 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.CertProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -73,6 +77,7 @@ fun SettingsScreen(onBack: () -> Unit) { verticalArrangement = Arrangement.spacedBy(32.dp), ) { Spacer(Modifier.height(0.dp)) + GearslipEnabledSection() CertificateSection() Spacer(Modifier.height(16.dp)) } @@ -89,6 +94,31 @@ private fun SectionTitle(text: String) { ) } +/** Off, a stock Android Auto app or head unit answers the USB connection instead of Gearslip. */ +@Composable +private fun GearslipEnabledSection() { + val context = LocalContext.current + var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionTitle("Gearslip") + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Enabled", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Switch(checked = enabled, onCheckedChange = { + enabled = it + AppSettings.setGearslipEnabled(context, it) + }) + } + if (!enabled) { + Text( + "Off - stock Android Auto can take the USB connection instead.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun CertificateSection() { val context = LocalContext.current diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt index 9abcb2d..a39cf98 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt @@ -90,6 +90,17 @@ private fun hasNotificationAccess(context: Context): Boolean { } private fun pages(context: Context, onRequestCallScreening: () -> Unit): List = buildList { + // --- Safety: always first, and never skipped by an already-granted check --------- + add( + GuidePage( + title = "Drive safely", + explanation = "This guide sets up the screen you will see mounted in your car. " + + "Gearslip is unofficial software: it is not made or reviewed by any carmaker, " + + "and it can fail without warning. Never interact with it while driving - set it " + + "up, mount the phone, then keep your attention on the road.", + ), + ) + // --- Permissions: skipped outright once already granted, nothing left to ask ----- if (CarSettings.pipeAudio.value && !granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) { add( diff --git a/gearslip/src/main/res/drawable/navigation_24.xml b/gearslip/src/main/res/drawable/navigation_24.xml new file mode 100644 index 0000000..b51adb1 --- /dev/null +++ b/gearslip/src/main/res/drawable/navigation_24.xml @@ -0,0 +1,9 @@ + + +