mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
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>
|
||||
Reference in new issue
Block a user