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.
This commit is contained in:
Seb3thehacker committed 2026-09-29 18:07:48 -07:00
1 parent e7f0aef43d
commit ae7c3ec308
39 files changed
+2045 -349

No files matched your search

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Debug-only override of the main icon background (src/debug/res wins over src/main/res),
so a debug install is never mistaken for a release one on the home screen. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M0,0h108v108h-108z"
android:fillColor="#f5c518"/>
<path
android:pathData="M54,2 A52,52 0 1,1 53.99,2 Z"
android:fillColor="#e6b400"/>
</vector>
@@ -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)
}
}
@@ -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)
@@ -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<Int> = 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<String> = synchronized(live) { live.toList() }
@@ -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<Int> = 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
@@ -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<Int> = 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<Int> = 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<Int>)
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",
@@ -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 ")
}
@@ -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)
}
}
@@ -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
@@ -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> = _state
/** Set once a recognition result comes in; [reply] clears it once handled. */
private val _pendingCommand = MutableStateFlow<String?>(null)
val pendingCommand: StateFlow<String?> = _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
}
}
@@ -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)) {
@@ -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(
@@ -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))
}
}
}
}
@@ -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) }
@@ -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<List<Reading>>(emptyList())
val readings: StateFlow<List<Reading>> = 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<Int>) {
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"
}
@@ -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<MediaApp?>(null)
val mediaApp: StateFlow<MediaApp?> = _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<Boolean> = _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>): 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
@@ -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<AppTheme> = themeFlow
private val pinnedFlow = MutableStateFlow<Set<String>>(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<Set<String>> = pinnedFlow
@@ -68,6 +76,40 @@ object CarSettings {
*/
val pipeAudio: StateFlow<Boolean> = 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<Boolean> = 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<Boolean> = 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<Boolean> = 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<Boolean> = 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)
@@ -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 <T> ChoiceRow(title: String, options: List<Pair<T, String>>, 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 },
)
}
}
@@ -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
@@ -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>(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,
@@ -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,
)
}
}
@@ -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 <name>". */
internal fun hasCallPermissions(context: Context): Boolean =
CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
private fun loadContacts(context: Context): List<Contact> {
internal fun loadContacts(context: Context): List<Contact> {
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList()
val out = mutableListOf<Contact>()
val projection = arrayOf(
@@ -80,7 +81,7 @@ private fun loadContacts(context: Context): List<Contact> {
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 },
)
}
}
@@ -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<String, List<Item>>(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<List<Item>>(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<Item>).orEmpty()
val fetched = (value as? List<Item>).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<CarRow>().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<CarRow>()
val tiles = items.filterIsInstance<GridItem>()
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,
)
}
}
}
}
@@ -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<ImageBitmap?>(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<GridItem>().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)
@@ -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,
)
}
}
}
@@ -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 <form> 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;
@@ -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")
@@ -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<String, ImageBitmap>(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. */
@@ -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
}
@@ -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
}
@@ -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<LyricLine>, 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<String, LyricsResult?>()
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 {
@@ -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() {
@@ -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,
)
}
}
}
@@ -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
@@ -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
@@ -90,6 +90,17 @@ private fun hasNotificationAccess(context: Context): Boolean {
}
private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<GuidePage> = 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(
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,2L4.5,20.29L5.21,21L12,18L18.79,21L19.5,20.29L12,2Z"/>
</vector>