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