Add phone call handling on the car screen

Incoming calls now show the caller (name via contacts, else number) on
the car UI with answer/decline, using a call screening service so the
car sees a call before it rings. Also adds pinnable nav bar shortcuts,
lowers minSdk to 31 for wider device support, and bumps to 0.0.04-pre.
This commit is contained in:
Seb3thehacker committed 2026-09-26 11:43:09 -07:00
1 parent 48be745659
commit 043dc11251
24 files changed
+1233 -353

No files matched your search

@@ -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() {
@@ -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<Int, Int>? = 1 to 3
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
const val MSG_VERSION_REQUEST = 1
const val MSG_VERSION_RESPONSE = 2
@@ -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>(CallUiState.None)
val state: StateFlow<CallUiState> = _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)
}
@@ -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
}
}
@@ -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,
)
}
}
}
@@ -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())
}
}
}
@@ -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<Entry>? = 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,
) {
@@ -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<Crumb> 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)
}
}
@@ -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()
@@ -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<AppTheme> = themeFlow
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
/** Flattened component names of the map and media apps used last, brought back on connect. */
val lastNav: StateFlow<String?> = 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(","))
}
}
@@ -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<androidx.compose.ui.graphics.ImageBitmap?>(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<Long> = 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)
}
}
@@ -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>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
value = LyricsState.Loading
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
@@ -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)
}
}
@@ -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<CarRow>().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)
}
}
@@ -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,
@@ -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. */
@@ -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.
*
@@ -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<Bitmap?>(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")
}
}
}
@@ -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 })
@@ -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")
}
}
}
}
}