diff --git a/.gitignore b/.gitignore
index 577fd18..b7dfe85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,4 +22,5 @@ keystore.properties
/docs/cert-sweep.sh
archive/
CLAUDE.md
-*.pdf
\ No newline at end of file
+*.pdf
+extraction/
\ No newline at end of file
diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts
index 1af3c21..0129d6c 100644
--- a/gearslip/build.gradle.kts
+++ b/gearslip/build.gradle.kts
@@ -30,10 +30,10 @@ android {
defaultConfig {
applicationId = "app.seb3thehacker.gearslip"
- minSdk = 35
+ minSdk = 31
targetSdk = 37
- versionCode = 3
- versionName = "0.0.03-pre"
+ versionCode = 4
+ versionName = "0.0.04-pre"
}
signingConfigs {
diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml
index cb2ee71..ae95693 100644
--- a/gearslip/src/main/AndroidManifest.xml
+++ b/gearslip/src/main/AndroidManifest.xml
@@ -39,6 +39,18 @@
android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
index ecc3434..d442192 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
@@ -104,6 +104,19 @@ class GearslipActivity : ComponentActivity(), Projection {
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted -> if (granted) Prefetch.warm(this, force = true) }
+ /**
+ * Caller name, incoming number and call state; declined just means no phone card on the car
+ * screen. Asked for up front, like location and the calendar, so nothing prompts mid-drive.
+ */
+ private val callPermissions = registerForActivityResult(
+ androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(),
+ ) { granted -> if (granted.values.any { it }) app.seb3thehacker.gearslip.call.CarCalls.start(this) }
+
+ /** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
+ private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
@@ -120,6 +133,15 @@ class GearslipActivity : ComponentActivity(), Projection {
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
+ val callPerms = arrayOf(
+ android.Manifest.permission.READ_PHONE_STATE,
+ android.Manifest.permission.READ_CALL_LOG,
+ android.Manifest.permission.READ_CONTACTS,
+ android.Manifest.permission.ANSWER_PHONE_CALLS,
+ )
+ if (callPerms.any { checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED }) {
+ callPermissions.launch(callPerms)
+ }
enableEdgeToEdge()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -137,7 +159,7 @@ class GearslipActivity : ComponentActivity(), Projection {
setContent {
GearslipTheme {
- GearslipApp(onDisconnect = ::closeAccessory)
+ GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
}
}
@@ -181,6 +203,19 @@ class GearslipActivity : ComponentActivity(), Projection {
GearslipLog.flush()
}
+ /** Launches the system dialog that offers Gearslip the call-screening role, if it isn't held already. */
+ private fun requestCallScreeningRole() {
+ val roleManager = getSystemService(android.app.role.RoleManager::class.java)
+ if (roleManager == null || !roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_CALL_SCREENING)) {
+ GearslipLog.w("calls: call screening role unavailable on this phone")
+ return
+ }
+ if (roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_CALL_SCREENING)) return
+ runCatching {
+ callScreeningRole.launch(roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_CALL_SCREENING))
+ }.onFailure { GearslipLog.e("could not ask for the call screening role", it) }
+ }
+
// --- Media audio --------------------------------------------------------------------
private fun requestAudioCapture() {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt
index 77b4358..b6d79cb 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt
@@ -181,16 +181,22 @@ class GearslipRunner(
log.i("<- VersionRequest: head unit speaks $major.$minor")
SessionReport.protocolVersion(major, minor)
- // Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than
- // asserting 1.6 is the permissive choice: we only need to reach service discovery,
- // not to honour whatever that version implies.
+ // PROTOCOL-DOWNGRADE EXPERIMENT. The 2018 Uconnect speaks 1.3 and ACCEPTS the JVC cert;
+ // the 2025 unit speaks 4.3 and rejects it at AuthComplete (-3). If the 4.3 identity check
+ // is tied to the *negotiated* protocol version rather than baked into the firmware,
+ // claiming an older version makes the newer unit skip the check entirely. CLAIM_VERSION
+ // = null restores the previous echo-the-head-unit behaviour.
+ val claim = CLAIM_VERSION ?: (major to minor)
val response = byteArrayOf(
- ((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(),
- ((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(),
+ ((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
+ ((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
0, 0, // STATUS_SUCCESS
)
send(MSG_VERSION_RESPONSE, response, encrypted = false)
- log.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
+ log.i(
+ "-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
+ if (CLAIM_VERSION != null) " [FORCED; head unit offered $major.$minor]" else "",
+ )
state = State.TLS_HANDSHAKE
SessionStatus.connecting("Securing the link")
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
@@ -787,6 +793,13 @@ class GearslipRunner(
}
private companion object {
+ /**
+ * Force the protocol version Gearslip claims in VersionResponse, as (major, minor), or
+ * null to echo the head unit. 1.3 is what the 2018 Uconnect (which accepts the JVC cert)
+ * speaks. This is the 2025 identity-check downgrade experiment: see onVersionRequest.
+ */
+ val CLAIM_VERSION: Pair? = 1 to 3
+
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
const val MSG_VERSION_REQUEST = 1
const val MSG_VERSION_RESPONSE = 2
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarCalls.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarCalls.kt
new file mode 100644
index 0000000..8ada839
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/call/CarCalls.kt
@@ -0,0 +1,135 @@
+package app.seb3thehacker.gearslip.call
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import android.provider.ContactsContract
+import android.telecom.TelecomManager
+import android.telephony.TelephonyCallback
+import android.telephony.TelephonyManager
+import app.seb3thehacker.gearslip.GearslipLog
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
+data class Caller(val number: String, val name: String?) {
+ val label: String get() = name ?: number.ifBlank { "Unknown number" }
+}
+
+sealed interface CallUiState {
+ data object None : CallUiState
+ data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
+ data class Active(val caller: Caller?) : CallUiState
+}
+
+/**
+ * Tracks the phone's call state for the car screen, and answers or declines through the two
+ * doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
+ * and [GearslipCallScreening]'s response to decline one that is still ringing.
+ *
+ * Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip
+ * cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
+ * from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
+ * stays on the phone's normal voice path too; nothing here sends it over the USB link, which
+ * only ever carries media playback.
+ */
+object CarCalls {
+
+ private val _state = MutableStateFlow(CallUiState.None)
+ val state: StateFlow = _state.asStateFlow()
+
+ private var telephony: TelephonyManager? = null
+ private var watcher: TelephonyCallback? = null
+ private var pendingRespond: ((allow: Boolean) -> Unit)? = null
+ private var ringingCaller: Caller? = null
+
+ fun start(context: Context) {
+ if (watcher != null) return
+ if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
+ val manager = context.getSystemService(TelephonyManager::class.java) ?: return
+ val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
+ override fun onCallStateChanged(state: Int) = onPhoneState(state)
+ }
+ runCatching { manager.registerTelephonyCallback(context.mainExecutor, callback) }
+ .onSuccess { telephony = manager; watcher = callback }
+ .onFailure { GearslipLog.w("calls: could not watch call state: ${it.message}") }
+ }
+
+ fun stop() {
+ val callback = watcher ?: return
+ runCatching { telephony?.unregisterTelephonyCallback(callback) }
+ telephony = null
+ watcher = null
+ pendingRespond = null
+ ringingCaller = null
+ _state.value = CallUiState.None
+ }
+
+ private fun onPhoneState(state: Int) {
+ when (state) {
+ TelephonyManager.CALL_STATE_IDLE -> {
+ pendingRespond = null
+ ringingCaller = null
+ _state.value = CallUiState.None
+ }
+ TelephonyManager.CALL_STATE_OFFHOOK -> {
+ pendingRespond = null
+ _state.value = CallUiState.Active(ringingCaller)
+ }
+ TelephonyManager.CALL_STATE_RINGING -> {
+ // The number itself only ever arrives through the screening service; if this
+ // fires first, the ringing card appears once incomingCall() catches up.
+ ringingCaller?.let { _state.value = CallUiState.Ringing(it, pendingRespond != null) }
+ }
+ }
+ }
+
+ /** Called by [GearslipCallScreening] the moment a call comes in - the only place with a number. */
+ fun incomingCall(context: Context, number: String?, respond: (allow: Boolean) -> Unit) {
+ val caller = Caller(number.orEmpty(), number?.let { lookUpName(context, it) })
+ ringingCaller = caller
+ pendingRespond = respond
+ _state.value = CallUiState.Ringing(caller, canDecline = true)
+ }
+
+ /** The screening window closed by itself - the call rings normally now, and can't be declined from here. */
+ fun screeningExpired() {
+ pendingRespond = null
+ val caller = ringingCaller ?: return
+ if (_state.value is CallUiState.Ringing) _state.value = CallUiState.Ringing(caller, canDecline = false)
+ }
+
+ fun decline() {
+ val respond = pendingRespond ?: return
+ pendingRespond = null
+ runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
+ }
+
+ fun answer(context: Context) {
+ if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
+ GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
+ return
+ }
+ val telecom = context.getSystemService(TelecomManager::class.java) ?: return
+ runCatching { telecom.acceptRingingCall() }
+ .onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
+ }
+
+ private fun lookUpName(context: Context, number: String): String? {
+ if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
+ return runCatching {
+ val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
+ context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
+ ?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
+ }.getOrNull()
+ }
+}
+
+/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
+internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
+ Handler(Looper.getMainLooper()).postDelayed(action, ms)
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/call/GearslipCallScreening.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/call/GearslipCallScreening.kt
new file mode 100644
index 0000000..60f3d87
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/call/GearslipCallScreening.kt
@@ -0,0 +1,53 @@
+package app.seb3thehacker.gearslip.call
+
+import android.telecom.Call
+import android.telecom.CallScreeningService
+import app.seb3thehacker.gearslip.GearslipLog
+
+/**
+ * Sees every incoming call before it rings, so the car screen can show who is calling and
+ * decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
+ * as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
+ * for, not a privileged permission.
+ *
+ * The system gives an app a few seconds to answer before it lets the call ring on its own, so a
+ * response is sent either when the driver taps a button or when that window is about to close,
+ * whichever comes first. Answering a call that has already passed this point still works, since
+ * [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
+ */
+class GearslipCallScreening : CallScreeningService() {
+
+ override fun onScreenCall(callDetails: Call.Details) {
+ val number = callDetails.handle?.schemeSpecificPart
+ var responded = false
+
+ val respond: (Boolean) -> Unit = { allow ->
+ if (!responded) {
+ responded = true
+ runCatching {
+ respondToCall(
+ callDetails,
+ CallResponse.Builder()
+ .setDisallowCall(!allow)
+ .setRejectCall(!allow)
+ .setSkipNotification(false)
+ .build(),
+ )
+ }.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
+ }
+ }
+
+ CarCalls.incomingCall(applicationContext, number, respond)
+
+ // Answer for the system if nobody has by the time its own patience runs out, so a call
+ // Gearslip is slow to show never gets silently blocked.
+ mainThreadDelay(SCREENING_WINDOW_MS) {
+ respond(true)
+ CarCalls.screeningExpired()
+ }
+ }
+
+ private companion object {
+ const val SCREENING_WINDOW_MS = 3_500L
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt
new file mode 100644
index 0000000..cb00795
--- /dev/null
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt
@@ -0,0 +1,132 @@
+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.Spacer
+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.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Call
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+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.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.seb3thehacker.gearslip.call.CallUiState
+import app.seb3thehacker.gearslip.call.CarCalls
+
+/**
+ * Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the
+ * whole screen, because nothing else matters more right now; an active call is a thin band along
+ * the top, since from here the driver can only see who it is, not end it - see [CarCalls].
+ */
+@Composable
+fun CallOverlay(modifier: Modifier = Modifier) {
+ val state by CarCalls.state.collectAsState()
+ val context = LocalContext.current.applicationContext
+
+ when (val s = state) {
+ // Covers the box regardless of where the caller aligned it, so the alignment below is
+ // only ever felt by the active-call banner, which is the one that isn't full-screen.
+ is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
+ is CallUiState.Active -> ActiveCallBanner(s, modifier)
+ CallUiState.None -> {}
+ }
+}
+
+@Composable
+private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline: () -> Unit) {
+ Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerLowest) {
+ Column(
+ Modifier.fillMaxSize().padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ Icons.Filled.Call,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.height(16.dp))
+ Text(
+ "Incoming call",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ s.caller.label,
+ style = MaterialTheme.typography.displaySmall,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(56.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
+ CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
+ CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
+ }
+ if (!s.canDecline) {
+ Spacer(Modifier.height(20.dp))
+ Text(
+ "Already ringing out loud - answer here, or let it ring.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ FilledIconButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = Modifier.size(72.dp),
+ colors = IconButtonDefaults.filledIconButtonColors(containerColor = color, contentColor = Color.White),
+ ) { Icon(icon, contentDescription = label, modifier = Modifier.size(30.dp)) }
+ Spacer(Modifier.height(8.dp))
+ Text(label, style = MaterialTheme.typography.labelLarge)
+ }
+}
+
+@Composable
+private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Row(
+ Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
+ Spacer(Modifier.height(0.dp))
+ Text(
+ " On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt
index f04261c..860c366 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt
@@ -1,10 +1,8 @@
package app.seb3thehacker.gearslip.car
-import androidx.compose.foundation.Image
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.fillMaxHeight
@@ -13,11 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
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.material.icons.Icons
-import androidx.compose.material.icons.filled.PlayArrow
-import androidx.compose.material3.FilledIconButton
-import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -27,32 +22,26 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.produceState
-import androidx.compose.runtime.remember
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 android.media.session.PlaybackState
-import androidx.compose.ui.graphics.asImageBitmap
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.media.CarMedia
-import app.seb3thehacker.gearslip.media.MediaArt
-import kotlinx.coroutines.delay
+import app.seb3thehacker.gearslip.media.NowPlaying
+import kotlinx.coroutines.launch
/**
- * The home screen: the map on the left, what is playing on the right.
+ * The home screen: the map on the left, lyrics on the right when asked for.
*
- * The map is whatever navigation app was used last, brought back on its own. The player only
- * takes its share of the screen only while something is playing or paused; otherwise the map has all of it.
+ * The map is whatever navigation app was used last, brought back on its own. Playback itself is
+ * controlled from the nav bar's pill everywhere, including here - this column only opens when the
+ * pill's lyrics button is on, and closes itself back to a full-width map once nothing is playing.
*/
@Composable
fun CarHome() {
@@ -65,21 +54,19 @@ fun CarHome() {
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
val navigator = LocalCarNavigator.current
- val showPlayer = phase == CarMedia.Phase.READY && now.isActive && !navigator.playerMinimised
- // Once playback ends there is nothing left to minimise; the next song starts in place.
- LaunchedEffect(now.isActive) { if (!now.isActive) navigator.restorePlayer() }
+ val showLyrics = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier
- .weight(if (showPlayer) 2.1f else 1f)
+ .weight(if (showLyrics) 2.1f else 1f)
.fillMaxHeight()
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
) {
MapPane(navStatus, frame)
}
- if (showPlayer) {
- MiniPlayer(Modifier.weight(1f).fillMaxHeight())
+ if (showLyrics) {
+ LyricsColumn(now, Modifier.weight(1f).fillMaxHeight())
}
}
}
@@ -98,6 +85,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
horizontalAlignment = Alignment.CenterHorizontally,
) {
val hasLast = CarSettings.lastNav.value != null
+ val scope = androidx.compose.runtime.rememberCoroutineScope()
when (status.phase) {
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
@@ -107,110 +95,56 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
TextButton(onClick = navigator::apps) { Text("Choose another app") }
}
else -> {
+ // 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) "Starting the map…" else "No map app chosen yet.",
+ if (hasLast) "Map stopped." else "No map app chosen yet.",
style = MaterialTheme.typography.titleMedium,
)
- if (!hasLast) {
+ 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,
)
- TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
}
+ TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
}
}
}
}
-/** Previous, play/pause, next and a seek bar, and nothing else: the full controls are in the media screen. */
+/**
+ * Lyrics for whatever the nav bar's pill is playing, beside the map. Transport controls live in
+ * the pill itself now, not here - this column has exactly one job.
+ */
@Composable
-private fun MiniPlayer(modifier: Modifier) {
- val media = CarServices.media
- val now by media.now.collectAsStateWithLifecycle()
- val context = LocalContext.current
- val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
-
- var position by remember { mutableStateOf(0L) }
- LaunchedEffect(now) {
- while (true) {
- position = now.currentPosition()
- delay(500)
- }
- }
-
+private fun LyricsColumn(now: NowPlaying, modifier: Modifier) {
val navigator = LocalCarNavigator.current
Surface(
- onClick = { navigator.media() },
modifier = modifier,
shape = RoundedCornerShape(EMBEDDED_RADIUS),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
- Column(
- Modifier.fillMaxSize().padding(18.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- ) {
- Box(Modifier.size(128.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
- art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
- }
- Spacer(Modifier.height(10.dp))
- Text(
- now.title.ifEmpty { "Nothing playing" },
- style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
- maxLines = 1, overflow = TextOverflow.Ellipsis,
- )
- Text(
- now.artist,
- style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1, overflow = TextOverflow.Ellipsis,
- )
-
- if (now.durationMs > 0) {
- WavyProgress(
- fraction = position.toFloat() / now.durationMs,
- playing = now.state == PlaybackState.STATE_PLAYING,
- onSeek = { media.seek((it * now.durationMs).toLong()) },
- modifier = Modifier.padding(vertical = 4.dp),
+ Column(Modifier.fillMaxSize().padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ now.title.ifEmpty { "Lyrics" },
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
)
- } else {
- Spacer(Modifier.height(16.dp))
- }
-
- Row(
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconButton(onClick = { media.previous() }, modifier = Modifier.size(48.dp)) {
- Icon(MediaIcons.Previous, "Previous", Modifier.size(30.dp))
- }
- FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(60.dp)) {
- Icon(
- if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
- if (now.playing) "Pause" else "Play",
- Modifier.size(36.dp),
- )
- }
- IconButton(onClick = { media.next() }, modifier = Modifier.size(48.dp)) {
- Icon(MediaIcons.Next, "Next", Modifier.size(30.dp))
+ IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) {
+ Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.dp))
}
}
-
- Spacer(Modifier.height(8.dp))
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- val compact = PaddingValues(horizontal = 8.dp)
- FilledTonalButton(
- onClick = { navigator.media(lyrics = true) },
- modifier = Modifier.weight(1f),
- contentPadding = compact,
- ) { Text("Lyrics", maxLines = 1, softWrap = false) }
- FilledTonalButton(
- onClick = navigator::minimisePlayer,
- modifier = Modifier.weight(1f),
- contentPadding = compact,
- ) { Text("Minimise", maxLines = 1, softWrap = false) }
- }
+ Spacer(Modifier.height(4.dp))
+ LyricsPanel(now, Modifier.weight(1f).fillMaxWidth())
}
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
index 18fc039..987d9a7 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt
@@ -1,6 +1,9 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.border
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -60,23 +63,29 @@ private class Tile(
val glyph: ImageVector? = null,
/** Tested from start to finish with Gearslip; drawn with a check mark. */
val verified: 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,
val onClick: () -> Unit,
)
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
-private class Entry(
+internal class Entry(
val label: String,
val icon: ImageBitmap?,
val template: TemplateApp? = null,
val media: MediaApp? = null,
-)
+) {
+ /** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
+ val componentId: String? get() = (template?.component ?: media?.component)?.flattenToString()
+}
/**
* The installed car and media apps with their icons. Scanning the package manager and decoding
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
* which is when new installs are picked up.
*/
-private object LauncherCache {
+internal object LauncherCache {
@Volatile var entries: List? = null
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
@@ -96,6 +105,16 @@ private object LauncherCache {
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
+/**
+ * Connects or opens [entry] - what tapping its tile does, shared with the nav bar's pinned
+ * shortcuts and its open-app icon so there is exactly one place that knows how to launch either
+ * kind of app.
+ */
+internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
+ entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
+ entry.media?.let { CarServices.openMedia(it); navigator.media() }
+}
+
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
@Composable
fun CarLauncher() {
@@ -108,15 +127,20 @@ fun CarLauncher() {
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
}
+ val pinned by CarSettings.pinnedApps.collectAsState()
+
val tiles = listOf(
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
) + installed.map { entry ->
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
- Tile(entry.label, entry.icon, verified = pkg != null && KnownApps.works(pkg)) {
- entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
- entry.media?.let { CarServices.openMedia(it); navigator.media() }
- }
+ val id = entry.componentId
+ Tile(
+ entry.label, entry.icon,
+ verified = pkg != null && KnownApps.works(pkg),
+ pinned = id != null && id in pinned,
+ onLongClick = id?.let { { CarSettings.togglePin(it) } },
+ ) { launchEntry(entry, navigator, frame) }
} + listOf(
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
)
@@ -164,12 +188,19 @@ private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AppTile(tile: Tile) {
+ val shape = MaterialTheme.shapes.extraLarge
Surface(
- onClick = tile.onClick,
- modifier = Modifier.height(120.dp),
- shape = MaterialTheme.shapes.extraLarge,
+ modifier = Modifier
+ .height(120.dp)
+ .let { if (tile.pinned) it.border(2.dp, MaterialTheme.colorScheme.primary, shape) else it }
+ // combinedClickable, not Surface's own onClick, so a long press can mean something
+ // different from a tap - pinning is deliberately the same gesture everywhere apps
+ // are shown (here and, once pinned, the shortcut itself on the nav bar).
+ .combinedClickable(onClick = tile.onClick, onLongClick = tile.onLongClick),
+ shape = shape,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt
index c2f610a..c52fc8d 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
@@ -24,58 +25,81 @@ sealed interface CarScreen {
data class App(val id: String) : CarScreen
}
+/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
+data class Crumb(val screen: CarScreen, val label: String)
+
/**
- * Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by
- * the on-screen one; an open app can claim it first (a browser going back a page) through
- * [backInterceptor].
+ * Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
+ * every screen that isn't reached from [home] is pushed onto a trail (like a browser's history,
+ * or a file manager's breadcrumb bar) rather than replacing the last one outright - that's what
+ * lets the breadcrumb bar show "Home > Apps > Settings" and jump straight back to any of them,
+ * and lets [back] undo exactly one step instead of always dumping you back at Home. An open app
+ * can claim a back step first (a browser going back a page) through [backInterceptor].
*/
class CarNavigator(startId: String) {
- var current: CarScreen by mutableStateOf(
- if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
+ private val stack = mutableStateListOf(
+ Crumb(
+ if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
+ "Home",
+ ),
)
- private set
+
+ /** The whole trail, root first. Size 1 means "at Home" - the breadcrumb bar hides then. */
+ val trail: List get() = stack
+
+ val current: CarScreen get() = stack.last().screen
var backInterceptor: (() -> Boolean)? = null
- fun home() { current = CarScreen.Home }
-
- /**
- * Minimised, the home screen's player folds into the nav bar and the map takes the width.
- * Opening the media screen from the bar restores it, so leaving that screen lands on the
- * home screen with the player back in place.
- */
- var playerMinimised by mutableStateOf(false)
- private set
-
- /** Set by the player's Lyrics button; the media screen reads it once when it opens. */
- var lyricsRequested = false
-
- fun minimisePlayer() { playerMinimised = true }
-
- fun restorePlayer() { playerMinimised = false }
-
- fun media(lyrics: Boolean = false) {
- lyricsRequested = lyrics
- playerMinimised = false
- current = CarScreen.Media
+ /** Pushes a new step, unless it's the same screen already on top - tapping the button for
+ * where you already are shouldn't grow the trail. */
+ private fun push(screen: CarScreen, label: String) {
+ if (stack.last().screen == screen) return
+ stack.add(Crumb(screen, label))
}
- fun apps() { current = CarScreen.Apps }
+ /** The one way back to a clean slate: resets the whole trail to just Home. */
+ fun home() {
+ stack.clear()
+ stack.add(Crumb(CarScreen.Home, "Home"))
+ }
- fun settings() { current = CarScreen.Settings }
+ /**
+ * The nav bar's now-playing pill is the player everywhere; this is only whether the home
+ * screen is additionally showing lyrics for it in the column beside the map. Toggled by the
+ * pill's own lyrics button, so it means the same thing wherever the pill is visible, even
+ * though only the home screen actually has the room to show it.
+ */
+ var lyricsOpen by mutableStateOf(false)
+ private set
- fun dashboard() { current = CarScreen.Dashboard }
+ fun toggleLyrics() { lyricsOpen = !lyricsOpen }
- fun weather() { current = CarScreen.Weather }
+ fun media() { push(CarScreen.Media, "Media") }
- fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
+ fun apps() { push(CarScreen.Apps, "Apps") }
- fun open(id: String) { current = CarScreen.App(id) }
+ fun settings() { push(CarScreen.Settings, "Settings") }
+ fun dashboard() { push(CarScreen.Dashboard, "Dashboard") }
+
+ fun weather() { push(CarScreen.Weather, "Weather") }
+
+ fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") }
+
+ fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
+
+ /** Jumps straight to a step already on the trail, dropping everything after it - what
+ * tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
+ fun jumpTo(index: Int) {
+ while (stack.size > index + 1) stack.removeAt(stack.lastIndex)
+ }
+
+ /** One step back, same as tapping the crumb before the current one. */
fun back() {
if (backInterceptor?.invoke() == true) return
- current = CarScreen.Home
+ if (stack.size > 1) stack.removeAt(stack.lastIndex)
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
index 007851a..178ef9e 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt
@@ -39,6 +39,7 @@ object CarServices {
fun init(appContext: Context) {
context = appContext.applicationContext
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
+ app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
}
// --- map -----------------------------------------------------------------------------
@@ -54,6 +55,12 @@ object CarServices {
nav.disconnect()
}
+ /** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */
+ suspend fun reconnectNav(frame: CarEnvironment.Frame) {
+ navStopped = false
+ autostart(frame)
+ }
+
// --- media ---------------------------------------------------------------------------
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
@@ -105,6 +112,7 @@ object CarServices {
/** 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() }
CarAudio.release()
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
index 63f1efa..e2e628e 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
@@ -24,6 +24,11 @@ object CarSettings {
private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_THEME = "car_app_theme"
+ private const val KEY_PINNED = "car_pinned_apps"
+
+ /** 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. */
+ const val MAX_PINNED = 3
/** Id of the app to open on connect; [HOME] opens the launcher. */
const val HOME = "home"
@@ -39,6 +44,10 @@ object CarSettings {
private val autoplayFlow = MutableStateFlow(false)
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow = themeFlow
+ private val pinnedFlow = MutableStateFlow>(emptySet())
+
+ /** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
+ val pinnedApps: StateFlow> = pinnedFlow
/** Flattened component names of the map and media apps used last, brought back on connect. */
val lastNav: StateFlow = lastNavFlow
@@ -73,6 +82,8 @@ object CarSettings {
nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO)
+ pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
+ .split(",").filter { it.isNotEmpty() }.toSet()
}
fun setScale(value: Float) {
@@ -115,4 +126,17 @@ object CarSettings {
AppSettings.putString(app, KEY_NIGHT, mode.name)
CarEnvironment.refresh()
}
+
+ /** Long-pressed onto or off the nav bar. Silently does nothing past [MAX_PINNED] rather than
+ * bumping the oldest pin - a driver who filled the bar on purpose shouldn't lose one by accident. */
+ fun togglePin(component: String) {
+ val current = pinnedFlow.value
+ val next = when {
+ component in current -> current - component
+ current.size >= MAX_PINNED -> return
+ else -> current + component
+ }
+ pinnedFlow.value = next
+ AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
+ }
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
index 584af21..ef562fd 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -19,9 +20,10 @@ import androidx.compose.foundation.layout.width
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.material.icons.filled.Home
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -44,6 +46,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
+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
@@ -55,8 +58,12 @@ 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
import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
@@ -129,11 +136,12 @@ fun CarUi() {
CarApps.find("calibrate")?.content?.invoke()
} else {
Column(Modifier.fillMaxSize().padding(pad)) {
+ BreadcrumbBar(navigator)
Box(Modifier.weight(1f).fillMaxWidth()) {
when (screen) {
CarScreen.Home -> CarHome()
CarScreen.Apps -> CarLauncher()
- CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
+ CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen()
CarScreen.Weather -> WeatherScreen()
@@ -144,6 +152,8 @@ fun CarUi() {
if (screen !is CarScreen.Notifications) {
NotificationPopup(Modifier.align(Alignment.TopCenter))
}
+ // Above the popup: a call is more urgent than any notification.
+ CallOverlay(Modifier.align(Alignment.TopCenter))
}
CarNavBar(navigator)
}
@@ -154,68 +164,166 @@ fun CarUi() {
}
/**
- * Home and Apps on the left; now-playing pill in the middle; clock (tap for calendar and weather), phone battery and the
- * darkness signal on the right. Icon-only: a driver reads a glyph faster than a label, and it
- * keeps the bar short enough to leave the app underneath more room.
+ * "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
+ * step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
+ * back at all, since a car touchscreen has no hardware button for it.
+ */
+@Composable
+private fun BreadcrumbBar(navigator: CarNavigator) {
+ val trail = navigator.trail
+ if (trail.size <= 1) return
+ Row(
+ Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = navigator::back, modifier = Modifier.size(36.dp)) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", Modifier.size(20.dp))
+ }
+ Spacer(Modifier.width(2.dp))
+ trail.forEachIndexed { index, crumb ->
+ val isLast = index == trail.lastIndex
+ Text(
+ crumb.label,
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
+ color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = if (isLast) Modifier else Modifier
+ .clip(RoundedCornerShape(6.dp))
+ .clickable { navigator.jumpTo(index) }
+ .padding(horizontal = 4.dp, vertical = 2.dp),
+ )
+ if (!isLast) {
+ Text(
+ "›",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 2.dp),
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
+ * calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
+ * reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
+ * more room.
*/
@Composable
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
- val darkOutside by CarEnvironment.darkOutside.collectAsState()
val now by rememberNow()
-
- // The home screen shows what's playing itself, unless it was minimised; everywhere else it lives in this bar.
- val showNowPlaying = navigator.current != CarScreen.Home || navigator.playerMinimised
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) {
+ value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
+ }
+ val pinnedEntries = remember(entries, pinnedIds) {
+ pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
+ }
+ val frame by CarEnvironment.frame.collectAsState()
- // One row, so nothing can sit on top of anything else: the player takes whatever width the
- // fixed items leave and shortens its title to fit.
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
- Row(
- Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
- Spacer(Modifier.width(6.dp))
- NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
+ BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
+ Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
+ // Home and Apps are two different places now, not a toggle on one button - the
+ // 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() }
+ Spacer(Modifier.width(6.dp))
+ NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
- Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
- if (showNowPlaying) NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
- }
-
- (weather as? WeatherState.Ready)?.data?.let { w ->
- NavChip(navigator.current == CarScreen.Weather, { navigator.weather() }) {
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- WeatherIcon(w.code, w.isDay, size = 28.dp)
- Text("${w.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ openMapComponent?.let { component ->
+ Spacer(Modifier.width(6.dp))
+ NavAppIcon(component, contentDescription = "Map", onClick = navigator::home)
+ }
+ pinnedEntries.forEach { entry ->
+ Spacer(Modifier.width(6.dp))
+ entry.componentId?.let { id ->
+ NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) }
}
}
- Spacer(Modifier.width(4.dp))
- }
- NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
- Column(horizontalAlignment = Alignment.End) {
- Text(
- DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- )
- val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else ""
- val light = if (darkOutside) "Night" else "Day"
- Text(
- listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
+
+ Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
+ NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
}
+
+ // Weather no longer has its own chip - the temperature alone (no icon, no room
+ // for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
+ // Home button above.
+ NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
+ Column(horizontalAlignment = Alignment.End) {
+ Text(
+ DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
+ if (battery.percent >= 0) {
+ Text(
+ "${battery.percent}%",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (battery.charging) {
+ ChargingGlyph(Modifier.size(13.dp))
+ }
+ }
+ weatherData?.let { w ->
+ Text(
+ " · ${w.temp}°",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ Spacer(Modifier.width(10.dp))
+ NavItem(
+ Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, padding = 8.dp,
+ ) { navigator.notifications() }
}
- Spacer(Modifier.width(10.dp))
- NavItem(
- Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
- ) { navigator.notifications() }
}
}
}
+/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
+@Composable
+private fun NavAppIcon(component: String, contentDescription: String?, onClick: () -> Unit) {
+ val context = LocalContext.current
+ val pkg = remember(component) { android.content.ComponentName.unflattenFromString(component)?.packageName }
+ val icon by produceState(null, pkg) {
+ value = pkg?.let {
+ runCatching { context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap() }.getOrNull()
+ }
+ }
+ Box(
+ Modifier
+ .size(44.dp)
+ .clip(RoundedCornerShape(14.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ icon?.let { Image(it, contentDescription, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
+ }
+}
+
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
@@ -231,14 +339,14 @@ private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable
}
@Composable
-private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
+private fun NavItem(icon: ImageVector, selected: Boolean, padding: androidx.compose.ui.unit.Dp = 12.dp, onClick: () -> Unit) {
val scheme = MaterialTheme.colorScheme
Box(
Modifier
.clip(RoundedCornerShape(18.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
- .padding(12.dp),
+ .padding(padding),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
@@ -254,7 +362,11 @@ internal fun rememberNow(): State = produceState(System.currentTimeMillis(
}
}
-/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
+/**
+ * The one persistent player, everywhere - not just a Home-screen extra. Art and title open the
+ * full media screen; skip and play/pause work right here; the last button toggles the lyrics
+ * panel beside the map on Home (see [CarHome]), the pill's own lyrics affordance wherever it sits.
+ */
@Composable
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media
@@ -302,9 +414,65 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
Modifier.size(26.dp),
)
}
- IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) {
- Icon(MediaIcons.Next, "Next", Modifier.size(28.dp))
+ FilledIconButton(
+ onClick = navigator::toggleLyrics,
+ modifier = Modifier.size(44.dp),
+ colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
+ containerColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.surfaceContainerHighest,
+ contentColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ ) {
+ LyricsGlyph(Modifier.size(22.dp))
}
}
}
}
+
+/**
+ * A card with two lines of text on it - read as "lyrics"/"captions" at a glance, which no icon in
+ * the core Material set does (the closest, a bullet list, reads as a menu, not song text). Drawn
+ * rather than a vector asset since the whole glyph is two shapes.
+ */
+@Composable
+private fun LyricsGlyph(modifier: Modifier = Modifier) {
+ val tint = androidx.compose.material3.LocalContentColor.current
+ androidx.compose.foundation.Canvas(modifier) {
+ val stroke = size.minDimension * 0.11f
+ drawRoundRect(
+ color = tint,
+ cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.22f),
+ style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
+ )
+ val lineStyle = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke * 0.85f, cap = androidx.compose.ui.graphics.StrokeCap.Round)
+ val startX = size.width * 0.24f
+ listOf(0.40f, 0.64f).forEach { fy ->
+ val endX = size.width * (if (fy < 0.5f) 0.76f else 0.62f)
+ drawLine(tint, androidx.compose.ui.geometry.Offset(startX, size.height * fy), androidx.compose.ui.geometry.Offset(endX, size.height * fy), strokeWidth = lineStyle.width, cap = lineStyle.cap)
+ }
+ }
+}
+
+/**
+ * A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
+ * core Material icon set this project ships (no extended pack, to keep the app small) has no
+ * bolt, so this is drawn the same way [LyricsGlyph] is.
+ */
+@Composable
+private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
+ androidx.compose.foundation.Canvas(modifier) {
+ val w = size.width
+ val h = size.height
+ val bolt = androidx.compose.ui.graphics.Path().apply {
+ moveTo(w * 0.58f, 0f)
+ lineTo(w * 0.12f, h * 0.58f)
+ lineTo(w * 0.46f, h * 0.58f)
+ lineTo(w * 0.42f, h)
+ lineTo(w * 0.88f, h * 0.40f)
+ lineTo(w * 0.54f, h * 0.40f)
+ close()
+ }
+ drawPath(bolt, tint)
+ }
+}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt
index b7b0472..00017be 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt
@@ -77,10 +77,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val browse by media.browse.collectAsStateWithLifecycle()
val rejection by media.rejection.collectAsStateWithLifecycle()
val capture by CarAudio.capture.collectAsStateWithLifecycle()
- val navigator = LocalCarNavigator.current
- var tab by remember {
- mutableStateOf(if (navigator.lyricsRequested) Tab.LYRICS else Tab.BROWSE).also { navigator.lyricsRequested = false }
- }
+ var tab by remember { mutableStateOf(Tab.BROWSE) }
// The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) }
@@ -145,7 +142,7 @@ private enum class Tab { BROWSE, LYRICS }
* the current line in the accent colour; unsynced ones are plain scrolling text.
*/
@Composable
-private fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
+internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
val lyrics by produceState(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
value = LyricsState.Loading
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt
index d4edbf9..89eb951 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt
@@ -1,6 +1,9 @@
package app.seb3thehacker.gearslip.car
import android.graphics.Rect
+import androidx.compose.foundation.Image
+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
@@ -13,6 +16,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -21,20 +30,29 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.car.app.model.CarIcon
import androidx.car.app.model.Template
import androidx.car.app.model.PlaceListMapTemplate
+import androidx.car.app.navigation.model.Lane
+import androidx.car.app.navigation.model.LaneDirection
import androidx.car.app.navigation.model.MapTemplate
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
+import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
+import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.color
+import app.seb3thehacker.gearslip.host.image
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime
import app.seb3thehacker.gearslip.host.text
@@ -96,7 +114,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
if (!pan) template.destinationTravelEstimate?.let {
- TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
+ TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
}
}
}
@@ -106,7 +124,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
if (info.isLoading) {
ChromeSurface(modifier) {
- Text("Finding a route…", Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
+ Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
}
return
}
@@ -120,89 +138,174 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
Column(Modifier.weight(1f)) {
val distance = info.currentDistance.display()
if (distance.isNotEmpty()) {
- Text(
- distance,
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- )
+ Text(distance, style = ChromeType.headline)
}
- Text(
- step.cue.text(),
- style = MaterialTheme.typography.titleMedium,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
+ Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
val road = step.road.text()
if (road.isNotEmpty()) {
Text(
road,
- style = MaterialTheme.typography.bodyMedium,
+ style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
+ // Neither is sent by every app - most say only "turn left" and trust the driver to
+ // find the lane themselves - so both are drawn only when there is something to draw.
+ info.junctionImage?.let { junction ->
+ Spacer(Modifier.height(10.dp))
+ JunctionImage(junction)
+ }
+ LaneGuidance(step, Modifier.padding(top = 10.dp))
info.nextStep?.let { next ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- "then",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
+ Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
- Text(
- next.cue.text(),
- style = MaterialTheme.typography.bodyMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
+ Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
-/** Time and distance left, along the bottom where it stays out of the map's way. */
+/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
+@Composable
+private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
+ val bitmap = icon.image(LocalContext.current) ?: return
+ Image(
+ bitmap,
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = modifier.fillMaxWidth().heightIn(max = 120.dp),
+ )
+}
+
+/**
+ * Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
+ * or as a row of arrows built from its structured lane data - whichever it sent. The recommended
+ * lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
+ * everything else uses, never dimmed by transparency.
+ */
+@Composable
+private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
+ step.lanesImage?.let {
+ JunctionImage(it, modifier)
+ return
+ }
+ val lanes = step.lanes.orEmpty()
+ if (lanes.isEmpty()) return
+ Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ lanes.forEach { lane -> LaneTile(lane) }
+ }
+}
+
+@Composable
+private fun LaneTile(lane: Lane) {
+ val directions = lane.directions.orEmpty()
+ val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
+ val recommended = shown?.isRecommended == true
+ Box(
+ Modifier
+ .size(36.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ if (recommended) MaterialTheme.colorScheme.primaryContainer
+ else MaterialTheme.colorScheme.surfaceContainerHighest,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.KeyboardArrowUp,
+ contentDescription = if (recommended) "Recommended lane" else "Lane",
+ tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
+ )
+ }
+}
+
+/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
+private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
+ LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
+ LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
+ LaneDirection.SHAPE_SHARP_RIGHT -> 135f
+ LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
+ LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
+ LaneDirection.SHAPE_NORMAL_LEFT -> -90f
+ LaneDirection.SHAPE_SHARP_LEFT -> -135f
+ LaneDirection.SHAPE_U_TURN_LEFT -> -170f
+ else -> 0f
+}
+
+/**
+ * The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
+ * where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
+ * full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
+ * that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
+ * actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
+ * first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
+ * going Home already does, rather than guess at the app's own controls and risk getting it wrong.
+ */
@Composable
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
- val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display()
+ val arrival = estimate.arrivalTimeAtDestination.clockTime()
val trip = estimate.tripText.text()
- if (time.isEmpty() && distance.isEmpty() && trip.isEmpty()) return
+ if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
- Surface(
- color = Color.Black.copy(alpha = 0.6f),
- contentColor = Color.White,
- modifier = modifier.fillMaxWidth(),
- ) {
+ val detail = listOfNotNull(
+ arrival.takeIf { it.isNotEmpty() },
+ distance.takeIf { it.isNotEmpty() },
+ trip.takeIf { it.isNotEmpty() },
+ ).joinToString(" • ")
+
+ // The same solid card the manoeuvre card above it uses - one surface for the whole map
+ // chrome, rather than this one spot styled on its own as a bar over the map.
+ ChromeSurface(modifier, shadowElevation = 8.dp) {
+ val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
Row(
- Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
- if (time.isNotEmpty()) {
- Text(
- time,
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- color = estimate.remainingTimeColor.color(dark, Color.White),
+ Box(
+ Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest)
+ // Not a disconnect: the connection stays up and the app decides its own next
+ // screen. Ending navigation is its own call, separate from a back press - a
+ // back press only pops the on-screen card, leaving the app still believing it
+ // is navigating (so a freshly picked destination silently does nothing); this
+ // is the call that actually clears that flag on the app's side.
+ .clickable { CarServices.nav.stopNavigating() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.Close,
+ contentDescription = "Stop navigating",
+ tint = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.size(20.dp),
)
}
- if (distance.isNotEmpty()) {
- Text(
- distance,
- style = MaterialTheme.typography.titleMedium,
- color = estimate.remainingDistanceColor.color(dark, Color.White),
- )
- }
- if (trip.isNotEmpty()) {
- Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
+ Spacer(Modifier.width(10.dp))
+ Column {
+ if (time.isNotEmpty()) {
+ Text(
+ time,
+ style = ChromeType.headline,
+ color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
+ )
+ }
+ if (detail.isNotEmpty()) {
+ Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
}
}
}
@@ -258,7 +361,10 @@ private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Mod
template.mapController?.mapActionStrip,
fullHeight = template.contentTemplate.needsFullHeight(),
) {
- ContentTemplate(template.contentTemplate, Modifier)
+ // A pane floating over the map is a place or a route the app is proposing, so its one
+ // action - Start - counts itself down rather than waiting to be pressed; the same pane
+ // filling a whole screen elsewhere gets no such push.
+ ContentTemplate(template.contentTemplate, Modifier, autoStartPane = true)
}
}
@@ -276,7 +382,7 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(),
)
- if (template.pane != null) PaneRows(template.pane)
+ if (template.pane != null) PaneRows(template.pane, autoStart = true)
else ItemListColumn(template.itemList)
}
}
@@ -322,13 +428,13 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
)
Box(Modifier.weight(1f, fill = false)) {
if (template.isLoading) {
- Text("Finding routes…", Modifier.padding(14.dp))
+ Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
} else {
ItemListColumn(template.itemList)
}
}
template.navigateAction?.let {
- ActionRow(listOf(it), Modifier.padding(12.dp))
+ AutoStartButton(it, Modifier.padding(12.dp))
}
}
}
@@ -346,7 +452,7 @@ private fun ListPane(
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(title, startAction)
- if (loading) Text("Loading…", Modifier.padding(14.dp))
+ if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title)
else ItemListColumn(itemList)
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt
index 8790fec..10a38cc 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt
@@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.GridTemplate
@@ -51,6 +52,7 @@ import androidx.car.app.media.model.MediaPlaybackTemplate
import androidx.car.app.OnDoneCallback
import androidx.car.app.serialization.Bundleable
import app.seb3thehacker.gearslip.GearslipLog
+import app.seb3thehacker.gearslip.host.isDrawable
import app.seb3thehacker.gearslip.host.inputChanged
import app.seb3thehacker.gearslip.host.inputSubmitted
import app.seb3thehacker.gearslip.host.submitted
@@ -65,12 +67,12 @@ 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) {
+fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStartPane: Boolean = false) {
when (template) {
null -> Unit
is ListTemplate -> ListContent(template, modifier)
is GridTemplate -> GridContent(template, modifier)
- is PaneTemplate -> PaneContent(template, modifier)
+ is PaneTemplate -> PaneContent(template, modifier, autoStartPane)
is MessageTemplate -> MessageContent(template, modifier)
is LongMessageTemplate -> LongMessageContent(template, modifier)
is SearchTemplate -> SearchContent(template, modifier)
@@ -126,7 +128,7 @@ private fun GridContent(template: GridTemplate, modifier: Modifier) {
}
@Composable
-private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
+private fun PaneContent(template: PaneTemplate, modifier: Modifier, autoStart: Boolean = false) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
@@ -134,7 +136,7 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
- if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane)
+ if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane, autoStart = autoStart)
}
}
@@ -143,27 +145,45 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
@Composable
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
ContentSurface(modifier) {
- HeaderBar(
- headerTitle(template.header, template.title.text()),
- template.header?.startHeaderAction ?: template.headerAction,
- template.header?.endHeaderActions.orEmpty(),
- template.actionStrip,
- )
+ // The header carries only the back arrow, not a title - a message template's own title
+ // is usually the long-form question ("Would you like to download the map...") that
+ // reads fine centred in the body but doesn't fit a one-line header without truncating.
+ val backAction = template.header?.startHeaderAction ?: template.headerAction
+ HeaderBar("", backAction, template.header?.endHeaderActions.orEmpty(), template.actionStrip)
if (template.isLoading) {
Loading()
return@ContentSurface
}
- Row(
- Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
+ // A back arrow already undoes this screen, so an app's own "Cancel" button would just
+ // be a second way to do the one thing the header already offers.
+ val actions = template.actions.orEmpty().filter { it.isDrawable() }.let { list ->
+ if (backAction != null) list.filterNot { it.title.text().equals("Cancel", ignoreCase = true) } else list
+ }
+ Column(
+ Modifier.weight(1f).fillMaxWidth().padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
template.icon?.let {
- CarGlyph(it, Modifier.size(32.dp))
- Spacer(Modifier.width(12.dp))
+ CarGlyph(it, Modifier.size(40.dp))
+ Spacer(Modifier.height(12.dp))
+ }
+ Text(template.title.text(), style = ChromeType.title, textAlign = TextAlign.Center)
+ val detail = template.message.text()
+ if (detail.isNotEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ detail,
+ style = ChromeType.body,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (actions.isNotEmpty()) {
+ Spacer(Modifier.height(24.dp))
+ ActionRow(actions, Modifier.fillMaxWidth(0.6f), large = true)
}
- Text(template.message.text(), style = MaterialTheme.typography.bodyLarge)
}
- ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
}
}
@@ -181,7 +201,7 @@ private fun LongMessageContent(template: LongMessageTemplate, modifier: Modifier
)
Text(
template.message.text(),
- style = MaterialTheme.typography.bodyMedium,
+ style = ChromeType.body,
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
@@ -245,7 +265,7 @@ private fun SearchField(query: String, hint: String, modifier: Modifier = Modifi
) {
Text(
query.ifEmpty { hint.ifEmpty { "Search" } },
- style = MaterialTheme.typography.titleMedium,
+ style = ChromeType.body,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -279,7 +299,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
if (instructions.isNotEmpty()) {
Text(
instructions,
- style = MaterialTheme.typography.bodyLarge,
+ style = ChromeType.body,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
@@ -298,7 +318,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
if (additional.isNotEmpty()) {
Text(
additional,
- style = MaterialTheme.typography.bodySmall,
+ style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
@@ -319,7 +339,7 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
if (error.isNotEmpty()) {
Text(
error,
- style = MaterialTheme.typography.bodySmall,
+ style = ChromeType.label,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp),
)
@@ -343,8 +363,7 @@ private fun Callout(value: String, modifier: Modifier) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
value,
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
+ style = ChromeType.headline,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
@@ -385,7 +404,7 @@ private fun TabContent(template: TabTemplate, modifier: Modifier) {
CarGlyph(it, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
}
- Text(tab.title.text(), style = MaterialTheme.typography.labelLarge, maxLines = 1)
+ Text(tab.title.text(), style = ChromeType.label, maxLines = 1)
}
}
}
@@ -450,7 +469,7 @@ private fun SectionRows(section: Section<*>) {
if (items.isEmpty()) {
val message = section.noItemsMessage.text()
- if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
+ if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
items.filterIsInstance().forEach { RowItem(it) }
@@ -474,7 +493,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
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 = MaterialTheme.typography.bodyMedium,
+ style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
@@ -496,6 +515,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
@Composable
private fun Loading() {
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
- Text("Loading…", style = MaterialTheme.typography.bodyLarge)
+ Text("Loading…", style = ChromeType.title)
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt
index cbe63e5..d178758 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt
@@ -4,12 +4,14 @@ import androidx.compose.runtime.remember
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -82,19 +84,21 @@ import app.seb3thehacker.gearslip.host.text
/**
* The app keeps drawing its map across the whole surface - the visible area we report tells it
* where to put its own markers, not where to stop painting. So anything the host lays over the
- * map is opaque by default, or the map reads through it.
+ * map is opaque, always, in the same one colour: a card that lets the map show through in places
+ * reads as broken glass, not as a deliberate choice, and a driver has no way to tell which was
+ * meant. There is no alpha knob here on purpose - every card, bar and strip in the template
+ * chrome shares this surface, so nothing drawn over a map is ever see-through.
*/
@Composable
internal fun ChromeSurface(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(16.dp),
- alpha: Float = 1f,
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
content: @Composable () -> Unit,
) {
Surface(
shadowElevation = shadowElevation,
- color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
+ color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = shape,
modifier = modifier,
@@ -102,6 +106,32 @@ internal fun ChromeSurface(
)
}
+/**
+ * The one type scale for everything a templated app's screen draws. Apps hand the host plain
+ * strings with no size of their own - the Car App Library deliberately keeps that decision out of
+ * their hands - so without a shared scale, each template's text ends up whatever size felt right
+ * when that template was written, and two apps' otherwise-identical rows read as different sizes
+ * of importance. Every car chrome file should reach for one of these four rather than naming a
+ * Material size directly.
+ */
+internal object ChromeType {
+ /** The one number or word a card exists to show: distance to a turn, minutes left, a PIN. */
+ val headline: androidx.compose.ui.text.TextStyle
+ @Composable get() = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)
+
+ /** A row's name, a header, a manoeuvre's instruction - the line the rest is supporting. */
+ val title: androidx.compose.ui.text.TextStyle
+ @Composable get() = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
+
+ /** Supporting detail: a road, an address, a message's body, a second line under a title. */
+ val body: androidx.compose.ui.text.TextStyle
+ @Composable get() = MaterialTheme.typography.titleMedium
+
+ /** The smallest text Gearslip draws over a map - still large enough to read while moving. */
+ val label: androidx.compose.ui.text.TextStyle
+ @Composable get() = MaterialTheme.typography.bodyLarge
+}
+
@Composable
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
@@ -109,7 +139,7 @@ internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier
Text(
"${template.javaClass.simpleName} isn't drawn yet",
Modifier.padding(16.dp),
- style = MaterialTheme.typography.titleMedium,
+ style = ChromeType.title,
)
}
}
@@ -142,8 +172,7 @@ internal fun HeaderBar(
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
Text(
title,
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.SemiBold,
+ style = ChromeType.title,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -285,12 +314,7 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
contentAlignment = Alignment.Center,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
- Text(
- title,
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- )
+ Text(title, style = ChromeType.title, maxLines = 1)
}
} else if (title.isEmpty()) {
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
@@ -304,12 +328,82 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
if (hasGlyph) Spacer(Modifier.width(8.dp))
- Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
+ Text(title, style = ChromeType.body, maxLines = 1)
}
}
}
}
+/**
+ * A large action that fires on its own after five seconds, filling to show the countdown - for
+ * the one button on a map-preview pane, where the app is proposing a single place or route and a
+ * driver glancing at the car screen has already decided. Tapping it, same as any other button,
+ * fires it at once; the fill is only ever a preview of what a touch already does.
+ */
+@Composable
+internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
+ var fraction by remember(action.identity()) { mutableStateOf(0f) }
+ var fired by remember(action.identity()) { mutableStateOf(false) }
+ fun fire() {
+ if (fired) return
+ fired = true
+ action.onClickDelegate.click("auto-start")
+ }
+ androidx.compose.runtime.LaunchedEffect(action.identity()) {
+ val steps = 60
+ repeat(steps) { step ->
+ kotlinx.coroutines.delay(AUTO_START_MS / steps)
+ fraction = (step + 1) / steps.toFloat()
+ }
+ fire()
+ }
+
+ val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
+ val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
+ val background = action.backgroundColor.color(dark, MaterialTheme.colorScheme.primary)
+ val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary
+ val hasGlyph = action.icon != null
+
+ Surface(
+ // An unfilled track, solid, not the button's own colour dimmed with alpha - so the map
+ // behind never shows through however much of the five seconds is left.
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ contentColor = content,
+ shape = RoundedCornerShape(28.dp),
+ modifier = modifier.fillMaxWidth().clickable(enabled = action.isEnabled) { fire() },
+ ) {
+ androidx.compose.foundation.layout.Box {
+ // The countdown itself: a plain fill sweeping left to right underneath the label.
+ // matchParentSize(), not fillMaxHeight() - this Box sits beside a label Box that
+ // wraps its own content, and the outer Box sizes itself to its tallest child; a
+ // plain fillMaxHeight() here has nothing bounded to fill up to but the whole screen,
+ // which is what was stretching the whole button that tall. matchParentSize() instead
+ // takes whatever size the label ends up being, without itself voting on what that is.
+ androidx.compose.foundation.layout.Box(Modifier.matchParentSize()) {
+ androidx.compose.foundation.layout.Box(
+ Modifier
+ .fillMaxWidth(fraction)
+ .fillMaxHeight()
+ .background(background),
+ )
+ }
+ androidx.compose.foundation.layout.Box(
+ Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
+ Text(
+ action.title.text().ifEmpty { "Start" },
+ style = ChromeType.title,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+}
+
+private const val AUTO_START_MS = 5_000L
+
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
@Composable
private fun AppIconBadge() {
@@ -376,8 +470,7 @@ internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
+ style = ChromeType.title,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@@ -394,7 +487,7 @@ internal fun ItemListColumn(
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
- if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
+ if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
@@ -430,7 +523,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
- style = MaterialTheme.typography.labelLarge,
+ style = ChromeType.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -438,7 +531,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
if (subtitle.isNotEmpty()) {
Text(
subtitle,
- style = MaterialTheme.typography.labelSmall,
+ style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -449,17 +542,24 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
}
}
+/**
+ * [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
+ * a single action counts itself down rather than waiting to be pressed, on the theory that a
+ * driver glancing at the car screen has already decided. It never applies to more than one
+ * action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
+ */
@Composable
-internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) {
+internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
+ val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
Column(modifier) {
rows.forEach { RowItem(it, large = true) }
- ActionRow(
- pane?.actions.orEmpty(),
- Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
- large = true,
- )
+ if (autoStart && actions.size == 1) {
+ AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
+ } else {
+ ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
+ }
}
}
@@ -496,18 +596,20 @@ internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolea
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
+ // large only changes how much room a row gets (more lines, a bigger icon above) - the
+ // type itself is the same scale as every other row, so a pane's one row and a plain
+ // list's tenth row read as the same kind of text.
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
- style = if (large) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.bodyLarge,
- fontWeight = if (large) FontWeight.SemiBold else null,
+ style = ChromeType.title,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text(
line.text(),
- style = if (large) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
+ style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
index f0db0ae..03cfcc2 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt
@@ -31,6 +31,7 @@ import androidx.car.app.suggestion.ISuggestionHost
import androidx.car.app.hardware.ICarHardwareHost
import androidx.car.app.hardware.ICarHardwareResult
import androidx.car.app.navigation.INavigationHost
+import androidx.car.app.navigation.INavigationManager
import androidx.car.app.serialization.Bundleable
import androidx.car.app.versioning.CarAppApiLevels
import app.seb3thehacker.gearslip.GearslipLog
@@ -81,6 +82,8 @@ class CarAppConnection(private val context: Context) {
private var carApp: ICarApp? = null
private var appManager: IAppManager? = null
+ /** Only present for an app that asked for it - most apps aren't navigation apps. */
+ private var navigationManager: INavigationManager? = null
/** Main thread only, both of these - the handoff is a race between them either way round. */
private var surfaceCallback: ISurfaceCallback? = null
private var lent: Lent? = null
@@ -189,6 +192,7 @@ class CarAppConnection(private val context: Context) {
binding = null
this.carApp = null
appManager = null
+ navigationManager = null
_panMode.value = false
surfaceCallback = null
component = null
@@ -269,6 +273,37 @@ class CarAppConnection(private val context: Context) {
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
requestTemplate()
}) { app.getManager(CarContext.APP_SERVICE, it) }
+
+ // Best-effort and silent: an app that never asked for NavigationManager (not every
+ // templated app navigates) simply has no binder to hand back, which is normal, not a
+ // failure worth surfacing.
+ runCatching {
+ app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
+ override fun onSuccess(response: Bundleable?) {
+ val value = response?.let { runCatching { it.get() }.getOrNull() }
+ navigationManager = when (value) {
+ is INavigationManager -> value
+ is IBinder -> INavigationManager.Stub.asInterface(value)
+ else -> null
+ }
+ }
+ override fun onFailure(response: Bundleable?) = Unit
+ })
+ }
+ }
+
+ /**
+ * Tells the app itself to stop navigating - the proper end of a route, not just leaving its
+ * screen. Ending only the on-screen guidance (a back press) leaves the app's own
+ * "am I navigating" flag set, which is what stopped a fresh route from starting afterwards;
+ * this is the call [NavigationManagerCallback.onStopNavigation] on the app's side answers,
+ * and it is expected to clear that flag and present its own post-navigation screen.
+ */
+ fun stopNavigating() {
+ val manager = navigationManager ?: return
+ GearslipLog.i("host: asking the app to stop navigating")
+ runCatching { manager.onStopNavigation(noop("stop navigation")) }
+ .onFailure { GearslipLog.w("host: could not stop navigation: ${it.message}") }
}
/** The app describes its current screen; this is what the host draws. */
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt
index 7405ba5..4acf1b3 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt
@@ -91,6 +91,17 @@ fun remainingTime(seconds: Long): String {
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
}
+/** "1:10 AM" - the clock at the destination, not the phone's own, in case a route crosses a zone. */
+fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
+ val at = this ?: return ""
+ val shifted = java.util.Date(at.timeSinceEpochMillis + at.zoneOffsetSeconds * 1000L)
+ return runCatching {
+ java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).apply {
+ timeZone = java.util.TimeZone.getTimeZone("UTC")
+ }.format(shifted)
+ }.getOrDefault("")
+}
+
/**
* Tells the app something was tapped.
*
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt
index 92dbde4..3a497bb 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt
@@ -3,7 +3,7 @@ package app.seb3thehacker.gearslip.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
-import android.content.pm.ActivityInfo
+import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.HardwareBuffer
@@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsCompat
@@ -70,7 +71,9 @@ fun CarPreviewScreen(onBack: () -> Unit) {
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
var bitmap by remember { mutableStateOf(null) }
var viewSize by remember { mutableStateOf(0 to 0) }
- var fullscreen by remember { mutableStateOf(false) }
+ // Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
+ // fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
+ val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val width = frame.width
val height = frame.height
@@ -149,7 +152,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
}
if (fullscreen) {
- Fullscreen(landscape = width > height, onExit = { fullscreen = false }) {
+ Fullscreen(onBack) {
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
// whole height, whichever the car's shape allows, and the rest stays black.
frameContent(Modifier)
@@ -174,7 +177,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
Row {
TextButton(onClick = onBack) { Text("Back") }
- TextButton(onClick = { fullscreen = true }) { Text("Fullscreen") }
}
}
}
@@ -184,34 +186,32 @@ fun CarPreviewScreen(onBack: () -> Unit) {
* Fills the phone screen with [content], centred and letterboxed.
*
* A car screen is wider than it is tall, so a portrait phone would waste most of its height on
- * one. While this is showing, the activity is turned to match and the system bars go away - both
- * are undone on the way out, so the rest of the phone UI is unaffected.
+ * one. This only shows once the phone is already turned sideways (the caller drives it off the
+ * phone's own orientation), so there is no rotation left to force here - just the system bars to
+ * hide while it's up and restore on the way out.
*/
@Composable
private fun Fullscreen(
- landscape: Boolean,
- onExit: () -> Unit,
+ onBack: () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
val activity = LocalContext.current.activity()
- DisposableEffect(activity, landscape) {
+ DisposableEffect(activity) {
if (activity == null) return@DisposableEffect onDispose { }
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
- val previousOrientation = activity.requestedOrientation
- if (landscape) activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
- onDispose {
- insets.show(WindowInsetsCompat.Type.systemBars())
- activity.requestedOrientation = previousOrientation
- }
+ onDispose { insets.show(WindowInsetsCompat.Type.systemBars()) }
}
- BackHandler(onBack = onExit)
+ // Turning the phone back upright is what normally leaves fullscreen; back still leaves the
+ // preview screen entirely rather than doing nothing, in case the phone's own rotation lock
+ // is on and turning it sideways/back isn't an option.
+ BackHandler(onBack = onBack)
Box(
Modifier.fillMaxSize().background(Color.Black),
@@ -222,10 +222,10 @@ private fun Fullscreen(
// The frame swallows every touch inside it, so leaving needs either the back gesture or
// a button of its own. This one sits in the corner the car UI leaves emptiest.
FilledTonalIconButton(
- onClick = onExit,
+ onClick = onBack,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
) {
- Icon(Icons.Filled.Close, contentDescription = "Leave fullscreen")
+ Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
}
}
}
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt
index 528dc94..1a430d1 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt
@@ -16,7 +16,7 @@ private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW }
/** Three screens and a back stack of depth one: no navigation library needed. */
@Composable
-fun GearslipApp(onDisconnect: () -> Unit) {
+fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
@@ -26,6 +26,7 @@ fun GearslipApp(onDisconnect: () -> Unit) {
onOpenSettings = { screen = Screen.SETTINGS },
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
onDisconnect = onDisconnect,
+ onRequestCallScreening = onRequestCallScreening,
)
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
index 0f9e9fa..66a98ea 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
@@ -47,6 +47,7 @@ fun HomeScreen(
onOpenSettings: () -> Unit,
onOpenCarPreview: () -> Unit,
onDisconnect: () -> Unit,
+ onRequestCallScreening: () -> Unit,
) {
val status by SessionStatus.state.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -79,7 +80,7 @@ fun HomeScreen(
Spacer(Modifier.height(16.dp))
StatusCard(status)
Spacer(Modifier.height(24.dp))
- SetupSteps()
+ SetupSteps(onRequestCallScreening)
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
@@ -184,12 +185,19 @@ private fun Context.appVersionName(): String =
/**
* One step of the setup list: a sentence that says what to do, and optionally how.
*
- * [command] is for the one step that cannot be done on the phone. It is shown as something to
- * copy rather than to read, because a mistyped package name fails silently.
+ * [command] is for a step that cannot be done on the phone; it is shown as something to copy
+ * rather than to read, because a mistyped package name fails silently. [onClick] is for a step
+ * that can, in one tap, in which case it is shown as a button instead.
*/
-private class Step(val action: String, val detail: String? = null, val command: String? = null)
+private class Step(
+ val action: String,
+ val detail: String? = null,
+ val command: String? = null,
+ val buttonLabel: String? = null,
+ val onClick: (() -> Unit)? = null,
+)
-private val SETUP_STEPS = listOf(
+private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
Step(
"On GrapheneOS, exempt Gearslip from exploit protections.",
@@ -216,6 +224,14 @@ private val SETUP_STEPS = listOf(
"music is going to the car, then puts it back.",
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
),
+ Step(
+ "Optional: let the car screen answer and decline calls.",
+ "This sets Gearslip as your phone's caller ID and spam-blocking app, the same kind of " +
+ "consent a call-blocking app asks for. Without it, calls still ring, but only the " +
+ "phone itself can answer or decline them.",
+ buttonLabel = "Set up",
+ onClick = onRequestCallScreening,
+ ),
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
)
@@ -254,7 +270,8 @@ private fun CopyableCommand(command: String) {
/** What to do before the first drive, in order. The same width as the status card above it. */
@Composable
-private fun SetupSteps() {
+private fun SetupSteps(onRequestCallScreening: () -> Unit) {
+ val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
@@ -268,7 +285,7 @@ private fun SetupSteps() {
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
- SETUP_STEPS.forEachIndexed { index, step ->
+ steps.forEachIndexed { index, step ->
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"${index + 1}",
@@ -287,6 +304,11 @@ private fun SetupSteps() {
)
}
step.command?.let { CopyableCommand(it) }
+ if (step.onClick != null) {
+ FilledTonalButton(onClick = step.onClick, modifier = Modifier.padding(top = 4.dp)) {
+ Text(step.buttonLabel ?: "Open")
+ }
+ }
}
}
}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e67dba8..46d5e0e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -25,4 +25,5 @@ dependencyResolutionManagement {
rootProject.name = "Gearslip"
include(":gearslip")
+include(":ksdump")
\ No newline at end of file