From 89b8157eb9f5b8e101483269ffe328f867edc839 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Wed, 30 Sep 2026 13:03:55 -0700 Subject: [PATCH] Messaging screen, units setting, turn pill, messaging app support --- .gitignore | 4 +- NOTICE.md | 13 +- gearslip/build.gradle.kts | 10 + .../res/drawable/ic_launcher_foreground.xml | 33 ++ gearslip/src/main/AndroidManifest.xml | 2 +- .../gearslip/car/AssistantOverlay.kt | 52 +- .../seb3thehacker/gearslip/car/CallOverlay.kt | 16 +- .../seb3thehacker/gearslip/car/CarAppStage.kt | 4 +- .../gearslip/car/CarDashboardScreen.kt | 13 +- .../gearslip/car/CarHomeScreen.kt | 157 ++++-- .../seb3thehacker/gearslip/car/CarKeyboard.kt | 26 +- .../seb3thehacker/gearslip/car/CarLauncher.kt | 146 ++++-- .../gearslip/car/CarNavigator.kt | 47 +- .../seb3thehacker/gearslip/car/CarServices.kt | 8 + .../seb3thehacker/gearslip/car/CarSettings.kt | 36 ++ .../gearslip/car/CarSettingsScreen.kt | 41 +- .../app/seb3thehacker/gearslip/car/CarUi.kt | 477 ++++++++++++------ .../seb3thehacker/gearslip/car/MediaScreen.kt | 343 +++++++------ .../gearslip/car/MessagesScreen.kt | 181 +++++++ .../app/seb3thehacker/gearslip/car/NavApps.kt | 239 +++++++++ .../gearslip/car/NotificationsScreen.kt | 73 ++- .../gearslip/car/PhoneAppsScreen.kt | 9 +- .../gearslip/car/PhoneDialerScreen.kt | 75 ++- .../seb3thehacker/gearslip/car/PlayerParts.kt | 236 +++++++++ .../gearslip/car/TemplateChrome.kt | 37 +- .../gearslip/car/TemplateContent.kt | 20 +- .../gearslip/car/TemplateParts.kt | 22 +- .../gearslip/car/WavyProgress.kt | 92 ---- .../app/seb3thehacker/gearslip/car/Weather.kt | 2 +- .../gearslip/car/theme/GsControls.kt | 326 ++++++++++++ .../gearslip/car/theme/GsSeekBar.kt | 83 +++ .../gearslip/car/theme/GsTheme.kt | 114 +++++ .../gearslip/host/CarAppConnection.kt | 31 +- .../gearslip/host/CarDescriptor.kt | 42 ++ .../seb3thehacker/gearslip/host/CarModels.kt | 36 +- .../gearslip/host/LocationKeepAlive.kt | 83 ++- .../seb3thehacker/gearslip/media/ArtColor.kt | 47 ++ .../seb3thehacker/gearslip/media/CarMedia.kt | 127 ++++- .../gearslip/media/MediaCatalog.kt | 24 +- .../gearslip/notify/CarNotifications.kt | 33 +- .../notify/GearslipNotificationListener.kt | 23 +- .../gearslip/notify/MessagingCatalog.kt | 39 ++ .../seb3thehacker/gearslip/ui/GearslipApp.kt | 2 +- gearslip/src/main/res/drawable/lyrics_24.xml | 12 + gearslip/src/main/res/drawable/mic_24.xml | 9 + gradle/libs.versions.toml | 3 + 46 files changed, 2691 insertions(+), 757 deletions(-) create mode 100644 gearslip/src/debug/res/drawable/ic_launcher_foreground.xml create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt delete mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsSeekBar.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarDescriptor.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/media/ArtColor.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/notify/MessagingCatalog.kt create mode 100644 gearslip/src/main/res/drawable/lyrics_24.xml create mode 100644 gearslip/src/main/res/drawable/mic_24.xml diff --git a/.gitignore b/.gitignore index 4079af0..9a36ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,10 @@ local.properties *.keystore keystore.properties -# Local investigation script; not meant for the public repo. /docs/cert-sweep.sh archive/ CLAUDE.md *.pdf extraction/ -.claude/ \ No newline at end of file +.claude/ +todo \ No newline at end of file diff --git a/NOTICE.md b/NOTICE.md index f8914c7..aff3759 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,16 +1,9 @@ # License -Gearslip is licensed under the **GNU Affero General Public License v3.0** (see `LICENSE`). +Gearslip is licensed under the GNU Affero General Public License v3.0. The full text is in `LICENSE`. -In short: you're free to use, run, and modify it. If you modify it — or run a modified -version as a service others use over a network — the AGPL requires you to make your source -changes available on the same terms. That obligation follows the code even when you don't -redistribute a binary, which is what makes the AGPL fit for something that can run as a -background service. +You may use, run, and modify Gearslip freely. If you distribute a modified version, or let others use one over a network, you must publish your changes under the same license. The obligation follows the code even when you hand out no binary, which suits software that runs as a background service. ## Commercial licensing -If the AGPL's terms don't work for you — for example, you want to use Gearslip privately -without being required to publish your changes — a separate commercial license is available -directly from the author. Terms (including any restrictions on modification) are set on a -case-by-case basis. Contact me though Matrix (on my profile) to discuss one. \ No newline at end of file +If the AGPL's terms don't work for you, the author sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact me on Matrix; the address is on my profile. diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts index f858ebc..40a5506 100644 --- a/gearslip/build.gradle.kts +++ b/gearslip/build.gradle.kts @@ -34,6 +34,7 @@ android { targetSdk = 37 versionCode = 5 versionName = "0.0.05-alpha" + manifestPlaceholders["appLabel"] = "Gearslip" } signingConfigs { @@ -53,6 +54,14 @@ android { release { signingConfig = signingConfigs.findByName("release") } + // Its own package, so a dev build installs beside the release one instead of over it. + // Signed with the release key when it's here: both builds declare the car template + // permission, and Android refuses a second app declaring it under a different key. + debug { + applicationIdSuffix = ".dev" + signingConfigs.findByName("release")?.let { signingConfig = it } + manifestPlaceholders["appLabel"] = "Gearslip Dev" + } } buildFeatures { @@ -108,6 +117,7 @@ dependencies { // their Bundleables deserialize into. implementation(libs.androidx.car.app) implementation(libs.androidx.car.app.projected) + implementation(libs.androidx.media) testImplementation(libs.junit) } diff --git a/gearslip/src/debug/res/drawable/ic_launcher_foreground.xml b/gearslip/src/debug/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ecb61a7 --- /dev/null +++ b/gearslip/src/debug/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index 3b98919..077e32c 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -91,7 +91,7 @@ android:allowBackup="false" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" - android:label="Gearslip" + android:label="${appLabel}" android:supportsRtl="true" android:theme="@style/Theme.Gearslip"> diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt index 56c3eba..8c7a161 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/AssistantOverlay.kt @@ -1,10 +1,9 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* import android.content.Intent import android.net.Uri import android.provider.Settings -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,10 +13,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -26,10 +22,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import app.seb3thehacker.gearslip.R import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -77,15 +72,13 @@ fun AssistantOverlay(modifier: Modifier = Modifier) { } } if (state.missingPermission) { - FilledTonalButton(onClick = { + GsButton(tone = GsTone.Tonal, onClick = { context.startActivity( Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)), ) }) { Text("Grant") } } - IconButton(onClick = CarAssistant::cancel) { - Icon(Icons.Filled.Close, contentDescription = "Dismiss") - } + GsIconButton(Icons.Filled.Close, "Dismiss", CarAssistant::cancel) } } } @@ -107,37 +100,8 @@ private fun PulsingMic(listening: Boolean, modifier: Modifier = Modifier) { // the glyph itself doesn't need to animate for this to read clearly on a glance. } -/** - * A mic capsule on a small stand, drawn rather than a vector asset - the trimmed core icon set - * this project ships (see [ChargingGlyph]'s doc comment) has no microphone. - */ +/** The assistant's mic, from `R.drawable.mic_24`, tinted with the current content colour. */ @Composable fun MicGlyph(modifier: Modifier = Modifier) { - val tint = LocalContentColor.current - Canvas(modifier) { - val w = size.width - val h = size.height - val capsuleWidth = w * 0.42f - val capsuleHeight = h * 0.58f - drawRoundRect( - color = tint, - topLeft = Offset((w - capsuleWidth) / 2f, 0f), - size = Size(capsuleWidth, capsuleHeight), - cornerRadius = CornerRadius(capsuleWidth / 2f), - ) - val standStroke = h * 0.09f - val standTop = capsuleHeight * 0.85f - val standBottom = h * 0.82f - drawArc( - color = tint, - startAngle = 20f, - sweepAngle = 140f, - useCenter = false, - topLeft = Offset(w * 0.14f, standTop - h * 0.06f), - size = Size(w * 0.72f, h * 0.5f), - style = Stroke(width = standStroke), - ) - drawLine(tint, Offset(w / 2f, standBottom - h * 0.05f), Offset(w / 2f, standBottom), strokeWidth = standStroke) - drawLine(tint, Offset(w * 0.30f, standBottom), Offset(w * 0.70f, standBottom), strokeWidth = standStroke) - } + Icon(ImageVector.vectorResource(R.drawable.mic_24), contentDescription = "Assistant", modifier = modifier) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt index cb00795..e8da410 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CallOverlay.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.foundation.shape.CircleShape +import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,9 +14,7 @@ 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 @@ -98,12 +98,14 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline @Composable private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - FilledIconButton( - onClick = onClick, + GsIconButton( + icon, label, onClick, + colors = GsColors(color, Color.White), enabled = enabled, - modifier = Modifier.size(72.dp), - colors = IconButtonDefaults.filledIconButtonColors(containerColor = color, contentColor = Color.White), - ) { Icon(icon, contentDescription = label, modifier = Modifier.size(30.dp)) } + size = 72.dp, + iconSize = 30.dp, + shape = CircleShape, + ) Spacer(Modifier.height(8.dp)) Text(label, style = MaterialTheme.typography.labelLarge) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt index af4a949..a42c619 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* import android.view.SurfaceHolder import android.view.SurfaceView import androidx.compose.foundation.gestures.detectTapGestures @@ -13,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -157,7 +157,7 @@ private fun ChromeBar( }.joinToString(" - ") Text(detail, style = MaterialTheme.typography.bodySmall) } - TextButton(onClick = onDisconnect) { Text("Stop", color = Color.White) } + GsButton(onClick = onDisconnect, tone = GsTone.Tonal) { Text("Stop") } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt index d34afec..1f398be 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt @@ -22,7 +22,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.produceState -import androidx.compose.foundation.clickable +import app.seb3thehacker.gearslip.car.theme.GsColors +import app.seb3thehacker.gearslip.car.theme.GsIconBox import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -43,7 +44,8 @@ import kotlinx.coroutines.withContext import org.json.JSONObject /** - * Opened from the nav bar clock: the week's calendar and the current weather, at a glance. Both + * Opened from the nav bar clock: the current weather, the week's calendar and the phone's + * notifications, at a glance. The first two * need permissions granted on the phone (READ_CALENDAR, location) - if either was declined * this just shows an empty state instead of asking again from inside the car UI. */ @@ -61,6 +63,7 @@ fun CarDashboardScreen() { Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) WeatherCard(context) AgendaCard(context) + NotificationsSection() } } @@ -70,10 +73,10 @@ private fun WeatherCard(context: Context) { val navigator = LocalCarNavigator.current LaunchedEffect(Unit) { Weather.refresh(context) } - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, + GsIconBox( + onClick = { navigator.weather() }, + colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), shape = MaterialTheme.shapes.extraLarge, - modifier = Modifier.clickable { navigator.weather() }, ) { Row( Modifier.fillMaxWidth().padding(20.dp), 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 165923e..7191589 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,12 +14,40 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import android.support.v4.media.session.PlaybackStateCompat +import app.seb3thehacker.gearslip.R +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import android.media.session.PlaybackState +import app.seb3thehacker.gearslip.media.MediaArt +import kotlinx.coroutines.delay import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import app.seb3thehacker.gearslip.media.ArtColor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -35,11 +64,11 @@ import app.seb3thehacker.gearslip.media.NowPlaying import kotlinx.coroutines.launch /** - * The home screen: the map on the left, lyrics on the right when asked for. + * The home screen: the map on the left, the docked player on the right when asked for. * - * 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. + * The map is whatever navigation app was used last, brought back on its own. The player sits in + * the nav bar's pill until its dock button moves it here; the column closes itself back to a + * full-width map once nothing is playing. */ @Composable fun CarHome() { @@ -52,10 +81,11 @@ fun CarHome() { LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() } val navigator = LocalCarNavigator.current - val splitScreen = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen + val panel = navigator.sidePanel + val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE // No outer padding on any side, split screen or not - only the gap between the two panes - // when lyrics are sharing the screen with the map. + // when the player is sharing the screen with the map. Row( Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -68,7 +98,8 @@ fun CarHome() { MapPane(navStatus, frame) } if (splitScreen) { - LyricsColumn(now, Modifier.weight(1f).fillMaxHeight()) + val side = Modifier.weight(1f).fillMaxHeight() + if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side) } } } @@ -95,7 +126,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> { Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium) status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } - TextButton(onClick = navigator::apps) { Text("Choose another app") } + GsButton(onClick = navigator::apps, tone = GsTone.Tonal) { Text("Choose another app") } } else -> if (!hasNavApps) { // Nothing to auto-select and nothing to pick from Car apps either - this is the @@ -111,8 +142,8 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame // 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("Map stopped.", style = MaterialTheme.typography.titleMedium) - TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") } - TextButton(onClick = { navigator.apps() }) { Text("Car apps") } + GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") } + GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") } } else { // Autostart is still picking one - a navigation app is installed, so this resolves // to a "Starting..." state itself, in the next frame or two. @@ -122,32 +153,96 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame } /** - * 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. + * The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat + * and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show + * twice. The X sends it back to the nav bar. */ @Composable -private fun LyricsColumn(now: NowPlaying, modifier: Modifier) { +private fun DockedPlayer(now: NowPlaying, modifier: Modifier) { val navigator = LocalCarNavigator.current - Surface( - modifier = modifier, - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Column(Modifier.fillMaxSize().padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { + val media = CarServices.media + val context = LocalContext.current + val scheme = MaterialTheme.colorScheme + val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } + + Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) { + BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) { + // Whatever height the controls below leave over, so the art grows on a taller + // screen and shrinks rather than pushing the buttons off a short one. + val artSize = minOf(maxWidth - 44.dp, maxHeight - 250.dp).coerceIn(56.dp, 200.dp) + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.fillMaxWidth()) { + // Art opens the full media screen, same as on the pill. + Box( + Modifier + .align(Alignment.TopCenter) + .size(artSize) + .clip(RoundedCornerShape(12.dp)) + .background(scheme.surface) + .clickable { navigator.media() }, + ) { + art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } + } + GsIconButton( + Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel, + Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp, + ) + } + Spacer(Modifier.height(10.dp)) Text( - now.title.ifEmpty { "Lyrics" }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), + now.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, + maxLines = 1, overflow = TextOverflow.Ellipsis, ) - IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) { - Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.dp)) + Text( + now.artist, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + if (now.durationMs > 0) { + PlayerProgress(now, media, Modifier.fillMaxWidth().padding(vertical = 2.dp), showTimes = false) + } else { + Spacer(Modifier.height(10.dp)) + } + TransportRow(now, media, skip = 48.dp, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(10.dp)) + PlayModeRow(now, media, size = 42.dp, modifier = Modifier.fillMaxWidth()) { + GsIconButton( + ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics, + size = 42.dp, iconSize = 22.dp, + ) } } - Spacer(Modifier.height(4.dp)) - LyricsPanel(now, Modifier.weight(1f).fillMaxWidth()) + } + } +} + +/** + * Lyrics alone beside the map, big enough to read at a glance. No art: that space goes to a + * larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock + * button swaps these back out for the controls. + */ +@Composable +private fun SideLyrics(now: NowPlaying, modifier: Modifier) { + val navigator = LocalCarNavigator.current + val context = LocalContext.current + val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } + Surface(modifier = modifier, color = rememberArtColor(art, MaterialTheme.colorScheme.surfaceVariant)) { + Column(Modifier.fillMaxSize().padding(16.dp)) { + Row(verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text( + now.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, + maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + Text( + now.artist, style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp) + } + Spacer(Modifier.height(12.dp)) + LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt index 3da3f0c..fc06773 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -25,7 +26,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.RoundedCornerShape /** @@ -138,11 +138,11 @@ fun CarKeyboard( @Composable private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = MaterialTheme.colorScheme.onSurface, + GsIconBox( + onClick = onClick, + modifier = modifier.height(KEY_HEIGHT), + colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), shape = RoundedCornerShape(8.dp), - modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick), ) { Row( Modifier.fillMaxWidth(), @@ -161,11 +161,11 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni @Composable private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + GsIconBox( + onClick = onClick, + modifier = modifier.height(KEY_HEIGHT), + colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant), shape = RoundedCornerShape(8.dp), - modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick), ) { Row( Modifier.fillMaxWidth(), @@ -179,11 +179,11 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) { @Composable private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) { - Surface( - color = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, + GsIconBox( + onClick = onClick, + modifier = modifier.height(KEY_HEIGHT), + colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary), shape = RoundedCornerShape(8.dp), - modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick), ) { Row( Modifier.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 4a5877c..04125e4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -1,9 +1,8 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* 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 @@ -16,19 +15,20 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Call -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap @@ -57,6 +57,8 @@ import app.seb3thehacker.gearslip.host.CarAppCatalog import app.seb3thehacker.gearslip.host.TemplateApp import app.seb3thehacker.gearslip.media.MediaApp import app.seb3thehacker.gearslip.media.MediaCatalog +import app.seb3thehacker.gearslip.notify.MessagingApp +import app.seb3thehacker.gearslip.notify.MessagingCatalog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -71,8 +73,6 @@ private class Tile( val broken: Boolean = false, /** Starts and can be used, but rough enough not to call working; drawn with a yellow mark. */ val partial: Boolean = false, - val pinned: Boolean = false, - /** Null for the built-in tiles (Web, Screen sharing, Settings) - nothing to pin them as. */ val onLongClick: (() -> Unit)? = null, val onClick: () -> Unit, ) @@ -83,18 +83,34 @@ internal class Entry( val icon: ImageBitmap?, val template: TemplateApp? = null, val media: MediaApp? = null, + val builtIn: BuiltInApp? = null, + val messaging: MessagingApp? = 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() + val componentId: String? + get() = builtIn?.let { BUILT_IN_PREFIX + it.id } + ?: messaging?.let { MESSAGING_PREFIX + it.packageName } + ?: (template?.component ?: media?.component)?.flattenToString() + + val packageName: String? + get() = template?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName } +/** A messaging app has no component of its own to open, so its pin carries the package instead. */ +internal const val MESSAGING_PREFIX = "messages:" + /** * 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. */ internal object LauncherCache { - @Volatile var entries: List? = null + /** Observed, not read once: the nav bar is drawn before the car session's rescan finishes, + * and an app installed since the last scan has to show up there when it does. */ + private val flow = MutableStateFlow?>(null) + val state: StateFlow?> = flow.asStateFlow() + + val entries: List? get() = flow.value /** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */ @Synchronized @@ -117,10 +133,29 @@ internal object LauncherCache { Entry(label, iconOf(it.component.packageName), template = it) } val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) } - return (nav + media).sortedBy { it.label.lowercase() }.also { entries = it } + // A messenger that is also a car media app keeps only its player tile. + val messaging = MessagingCatalog.installed(context) + .filter { it.packageName !in mediaPackages } + .map { Entry(it.label, iconOf(it.packageName), messaging = it) } + return (nav + media + messaging).sortedBy { it.label.lowercase() }.also { flow.value = it } } } +/** Every app the launcher knows: Gearslip's own and the installed ones, rescans included. */ +@Composable +internal fun rememberAllEntries(): List { + val context = LocalContext.current + val installed by LauncherCache.state.collectAsState() + LaunchedEffect(context) { + if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(context) } + } + return remember(installed) { BuiltInApps.entries + installed.orEmpty() } +} + +/** An app by the id a pin or the running list knows it by, built-in or installed. */ +internal fun findEntry(id: String): Entry? = + BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id } + /** 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) } @@ -130,6 +165,7 @@ fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = tru * kind of app. */ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) { + entry.builtIn?.let { it.open(navigator); return } entry.template?.let { if (it.isNavigation) { CarServices.connectNav(it, frame) @@ -140,48 +176,38 @@ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnviro } } entry.media?.let { CarServices.openMedia(it); navigator.media() } + entry.messaging?.let { navigator.messages(it.packageName, it.label) } } /** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */ @Composable fun CarLauncher() { - val context = LocalContext.current val navigator = LocalCarNavigator.current val frame by CarEnvironment.frame.collectAsState() // Painted from the cache at once; scanned only if [Prefetch] has not got there first. - val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) { - value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) } - } + val all = rememberAllEntries() + val running = rememberRunningApps() + val showVehicleData = rememberVehicleDataShown() + var showBadgeKey by remember { mutableStateOf(false) } - val pinned by CarSettings.pinnedApps.collectAsState() - val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() - val carSensorsEnabled by CarSettings.carSensorsEnabled.collectAsState() - - val leadingTiles = buildList { - add(Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") }) - add(Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") }) - add(Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") }) - // Experimental: raw, unverified sensor labels - see CarSensors. - if (experimentalFeatures && carSensorsEnabled) { - add(Tile("Vehicle data", glyph = Icons.Filled.Info) { navigator.vehicleData() }) - } - } - - val tiles = leadingTiles + installed.map { entry -> - val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName + fun tileOf(entry: Entry): Tile { + val pkg = entry.packageName val id = entry.componentId - Tile( - entry.label, entry.icon, + return Tile( + entry.label, entry.icon, entry.builtIn?.glyph, verified = pkg != null && KnownApps.works(pkg), broken = pkg != null && KnownApps.isBroken(pkg), partial = pkg != null && KnownApps.isPartial(pkg), - pinned = id != null && id in pinned, - onLongClick = id?.let { { CarSettings.togglePin(it) } }, - ) { launchEntry(entry, navigator, frame) } - } + listOf( - Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() }, - ) + onLongClick = id?.let { { navigator.showAppMenu(it) } }, + ) { openApp(entry, id?.let { running[it] }, navigator, frame) } + } + + // Gearslip's own apps first, then everything installed, then Settings last where it's expected. + val builtIns = all.filter { it.builtIn != null && (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) } + val tiles = builtIns.filter { it.builtIn !== BuiltInApps.Settings }.map(::tileOf) + + all.filter { it.builtIn == null }.map(::tileOf) + + builtIns.filter { it.builtIn === BuiltInApps.Settings }.map(::tileOf) LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 108.dp), @@ -192,12 +218,23 @@ fun CarLauncher() { ) { items(tiles) { AppTile(it) } if (tiles.any { it.verified || it.broken || it.partial }) { + // Folded away behind one button: it's reference, read once, not something every + // visit to the launcher needs spelled out under the grid. item(span = { GridItemSpan(maxLineSpan) }) { - BadgeLegend( - verified = tiles.any { it.verified }, - partial = tiles.any { it.partial }, - broken = tiles.any { it.broken }, - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + GsButton(onClick = { showBadgeKey = !showBadgeKey }, tone = GsTone.Neutral) { + Icon(Icons.Filled.Info, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(if (showBadgeKey) "Hide badge key" else "What the badges mean") + } + if (showBadgeKey) { + BadgeLegend( + verified = tiles.any { it.verified }, + partial = tiles.any { it.partial }, + broken = tiles.any { it.broken }, + ) + } + } } } } @@ -263,17 +300,14 @@ private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = @Composable private fun AppTile(tile: Tile) { val shape = MaterialTheme.shapes.extraLarge - Surface( - 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), + // A long press opens the app's menu (pin, close) - the same gesture everywhere apps are + // shown, here and on the nav bar. + GsIconBox( + onClick = tile.onClick, + onLongClick = tile.onLongClick, + modifier = Modifier.height(120.dp), + colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), shape = shape, - color = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, ) { Box(Modifier.fillMaxSize()) { Column( 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 e3093fd..b235843 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt @@ -30,11 +30,16 @@ sealed interface CarScreen { */ data object Browse : CarScreen + /** A messaging app's recent conversations, read from the notifications it posts. */ + data class Messages(val packageName: String) : CarScreen + /** [replyTo] is a notification key to open straight into its reply pane. */ data class Notifications(val replyTo: String? = null) : CarScreen data class App(val id: String) : CarScreen } +enum class SidePanel { NONE, CONTROLS, LYRICS } + /** 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) @@ -76,15 +81,38 @@ class CarNavigator(startId: String) { } /** - * 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. + * What the column beside the map on Home is showing, if anything. The player lives in one + * place at a time: with [SidePanel.CONTROLS] it moves into the column and the nav bar's pill + * steps aside; with [SidePanel.LYRICS] the column is all lyrics and the pill comes back. + * Every other screen has no room for the column, so there the pill stays in the nav bar. */ - var lyricsOpen by mutableStateOf(false) + var sidePanel by mutableStateOf(SidePanel.NONE) private set - fun toggleLyrics() { lyricsOpen = !lyricsOpen } + /** + * The pill's dock button: moves the player beside the map, going Home first if need be. With + * lyrics already there it swaps them for the controls; with the controls there it closes. + */ + fun togglePlayerDock() { + if (current != CarScreen.Home) { + sidePanel = SidePanel.CONTROLS + home() + return + } + sidePanel = if (sidePanel == SidePanel.CONTROLS) SidePanel.NONE else SidePanel.CONTROLS + } + + fun showSideLyrics() { sidePanel = SidePanel.LYRICS } + + fun closeSidePanel() { sidePanel = SidePanel.NONE } + + /** Component id of the app whose long-press menu (pin, close) is open, or null. */ + var appMenu by mutableStateOf(null) + private set + + fun showAppMenu(componentId: String) { appMenu = componentId } + + fun dismissAppMenu() { appMenu = null } fun media() { push(CarScreen.Media, "Media") } @@ -100,7 +128,12 @@ class CarNavigator(startId: String) { fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") } - fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") } + fun messages(packageName: String, label: String) { push(CarScreen.Messages(packageName), label) } + + /** The list lives on the dashboard; only a reply gets a screen of its own. */ + fun notifications(replyTo: String? = null) { + if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply") + } fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) } 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 aa7e4db..ad1086e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -117,6 +117,14 @@ object CarServices { if (CarSettings.pipeAudio.value) CarAudio.request() } + /** Closes the media app from the car: stops what it's playing and drops the connection. */ + fun closeMedia() { + if (media.now.value.playing) media.togglePlay() + media.disconnect() + _mediaApp.value = null + autoplayPending = false + } + /** Called once the media app is connected: starts playback if that was asked for. */ fun autoplayIfDue() { if (!autoplayPending) return 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 b4aeb80..0371290 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -2,11 +2,16 @@ package app.seb3thehacker.gearslip.car import android.content.Context import app.seb3thehacker.gearslip.AppSettings +import app.seb3thehacker.gearslip.car.theme.GsTheme +import app.seb3thehacker.gearslip.car.theme.GsThemes import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow enum class NightMode { AUTO, DAY, NIGHT } +/** Distances and temperatures. [AUTO] goes by the phone's region. */ +enum class Units { AUTO, IMPERIAL, METRIC } + /** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */ enum class AppTheme { PHONE, LIGHT, DARK } @@ -24,11 +29,13 @@ 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_UI_THEME = "car_ui_theme" private const val KEY_PINNED = "car_pinned_apps" private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features" private const val KEY_VOICE_ASSISTANT = "car_voice_assistant" private const val KEY_CAR_SENSORS = "car_sensors_enabled" private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning" + private const val KEY_UNITS = "car_units" /** 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. */ @@ -48,11 +55,31 @@ object CarSettings { private val autoplayFlow = MutableStateFlow(false) private val themeFlow = MutableStateFlow(AppTheme.PHONE) val appTheme: StateFlow = themeFlow + private val uiThemeFlow = MutableStateFlow(GsThemes.default.id) + + /** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */ + val uiTheme: StateFlow = uiThemeFlow private val pinnedFlow = MutableStateFlow>(emptySet()) private val experimentalFeaturesFlow = MutableStateFlow(false) private val voiceAssistantFlow = MutableStateFlow(false) private val carSensorsFlow = MutableStateFlow(false) private val seenSafetyWarningFlow = MutableStateFlow(false) + private val unitsFlow = MutableStateFlow(Units.AUTO) + + val units: StateFlow = unitsFlow + + /** Whether to show miles, feet and °F; [Units.AUTO] follows the countries that still use them. */ + fun imperial(): Boolean = when (unitsFlow.value) { + Units.IMPERIAL -> true + Units.METRIC -> false + Units.AUTO -> java.util.Locale.getDefault().country in setOf("US", "LR", "MM") + } + + fun setUnits(units: Units) { + unitsFlow.value = units + AppSettings.putString(app, KEY_UNITS, units.name) + Weather.refresh(app, force = true) + } /** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */ val pinnedApps: StateFlow> = pinnedFlow @@ -119,6 +146,7 @@ object CarSettings { themeFlow.value = runCatching { AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name)) }.getOrDefault(AppTheme.PHONE) + uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id) autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true" pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true" nightFlow.value = runCatching { @@ -130,6 +158,9 @@ object CarSettings { voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true" carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true" + unitsFlow.value = runCatching { + Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name)) + }.getOrDefault(Units.AUTO) } fun setScale(value: Float) { @@ -157,6 +188,11 @@ object CarSettings { AppSettings.putString(app, KEY_THEME, theme.name) } + fun setUiTheme(id: String) { + uiThemeFlow.value = id + AppSettings.putString(app, KEY_UI_THEME, id) + } + fun setAutoplay(on: Boolean) { autoplayFlow.value = on AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index f7be5e1..d56b940 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* import android.os.Build import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -30,6 +31,9 @@ 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 app.seb3thehacker.gearslip.car.theme.GsColors +import app.seb3thehacker.gearslip.car.theme.GsIconBox +import app.seb3thehacker.gearslip.car.theme.GsThemes import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.ui.CertSummary import kotlinx.coroutines.Dispatchers @@ -45,9 +49,11 @@ fun CarSettingsScreen() { val scale by CarSettings.scale.collectAsState() val open by CarSettings.openOnConnect.collectAsState() val night by CarSettings.nightMode.collectAsState() + val units by CarSettings.units.collectAsState() val pipeAudio by CarSettings.pipeAudio.collectAsState() val autoplay by CarSettings.autoplay.collectAsState() val theme by CarSettings.appTheme.collectAsState() + val uiTheme by CarSettings.uiTheme.collectAsState() val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState() val voiceAssistant by CarSettings.voiceAssistantEnabled.collectAsState() val carSensors by CarSettings.carSensorsEnabled.collectAsState() @@ -85,12 +91,24 @@ fun CarSettingsScreen() { theme, ) { CarSettings.setAppTheme(it) } + ChoiceRow( + "Button style", + GsThemes.builtIn.map { it.id to it.name }, + uiTheme, + ) { CarSettings.setUiTheme(it) } + ChoiceRow( "Map day/night", listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"), night, ) { CarSettings.setNightMode(it) } + ChoiceRow( + "Units", + listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"), + units, + ) { CarSettings.setUnits(it) } + ChoiceRow( "Autoplay on connect", listOf(false to "Off", true to "On"), @@ -141,18 +159,17 @@ private fun ChoiceRow(title: String, options: List>, selecte Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { options.forEach { (value, label) -> val on = value == selected - Surface( + val scheme = MaterialTheme.colorScheme + GsIconBox( onClick = { onSelect(value) }, modifier = Modifier .weight(1f) .height(64.dp), - shape = MaterialTheme.shapes.large, - color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer) + else GsColors(scheme.surfaceContainerHigh, scheme.onSurface), + latched = on, ) { - Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { - Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal) - } + Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal) } } } @@ -301,7 +318,7 @@ private fun ScreenFit(vehicle: String) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Button( + GsButton( onClick = { val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets) saved = if (ok) "Saved to $vehicle" else "Could not save" @@ -332,14 +349,12 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) { @Composable private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) { - Surface( + GsIconBox( onClick = onClick, modifier = modifier.height(64.dp), + colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - Text(text, style = MaterialTheme.typography.titleLarge) - } + Text(text, style = MaterialTheme.typography.titleLarge) } } 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 7104e66..2cf8aad 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -13,20 +13,28 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.background +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.Brush +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.border import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Notifications +import androidx.compose.foundation.layout.BoxScope +import androidx.car.app.model.CarIcon +import androidx.car.app.navigation.model.NavigationTemplate +import androidx.car.app.navigation.model.RoutingInfo +import app.seb3thehacker.gearslip.host.CarAppConnection +import app.seb3thehacker.gearslip.host.display +import app.seb3thehacker.gearslip.host.text +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Button -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -40,6 +48,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.animation.animateContentSize +import android.media.session.PlaybackState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,6 +65,14 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import app.seb3thehacker.gearslip.R +import app.seb3thehacker.gearslip.car.theme.GsColors +import app.seb3thehacker.gearslip.car.theme.GsIconBox +import app.seb3thehacker.gearslip.car.theme.GsIconButton +import app.seb3thehacker.gearslip.car.theme.GsButton +import app.seb3thehacker.gearslip.car.theme.GsTone +import app.seb3thehacker.gearslip.car.theme.gsColors +import app.seb3thehacker.gearslip.car.theme.GsThemes +import app.seb3thehacker.gearslip.car.theme.LocalGsTheme import app.seb3thehacker.gearslip.media.CarMedia import app.seb3thehacker.gearslip.media.MediaArt import androidx.compose.ui.platform.LocalContext @@ -79,6 +99,7 @@ fun CarUi() { val scale by CarSettings.scale.collectAsState() val phoneDark by CarEnvironment.phoneDark.collectAsState() val appTheme by CarSettings.appTheme.collectAsState() + val uiThemeId by CarSettings.uiTheme.collectAsState() val mediaApp by CarServices.mediaApp.collectAsState() val darkOutside by CarEnvironment.darkOutside.collectAsState() val appContext = LocalContext.current.applicationContext @@ -120,6 +141,7 @@ fun CarUi() { LocalCarNavigator provides navigator, LocalDarkOutside provides darkOutside, LocalMapControlsWake provides controlsWake, + LocalGsTheme provides GsThemes.byId(uiThemeId), ) { MaterialTheme(colorScheme = colors) { Surface( @@ -145,7 +167,8 @@ fun CarUi() { } val screen = navigator.current val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState() - Column(Modifier.fillMaxSize().padding(pad)) { + Box(Modifier.fillMaxSize().padding(pad)) { + Column(Modifier.fillMaxSize()) { if (!safetyWarningSeen) { CarSafetyWizard(onDone = CarSettings::markSafetyWarningSeen) return@Column @@ -163,10 +186,11 @@ fun CarUi() { CarScreen.Weather -> WeatherScreen() CarScreen.VehicleData -> VehicleDataScreen() is CarScreen.Notifications -> NotificationsScreen(screen.replyTo) + is CarScreen.Messages -> MessagesScreen(screen.packageName) is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke() } - // The history screen is already showing them, so it needs no popup. - if (screen !is CarScreen.Notifications) { + // The dashboard is already listing them, so it needs no popup. + if (screen !is CarScreen.Notifications && screen != CarScreen.Dashboard) { NotificationPopup(Modifier.align(Alignment.TopCenter)) } // Above the popup: a call is more urgent than any notification. @@ -186,6 +210,9 @@ fun CarUi() { val keyboardVisible by CarKeyboardVisibility.state if (!keyboardVisible) CarNavBar(navigator) } + // Over everything, nav bar included: a long press on an app, anywhere. + AppMenu(navigator, rememberRunningApps()) + } } } } @@ -224,7 +251,7 @@ private fun CarSafetyWizard(onDone: () -> Unit) { } } Spacer(Modifier.height(24.dp)) - Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { + GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { Text("I understand", style = ChromeType.body) } } @@ -281,9 +308,7 @@ private fun BreadcrumbBar(navigator: CarNavigator) { 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)) - } + GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp) Spacer(Modifier.width(2.dp)) trail.forEachIndexed { index, crumb -> val isLast = index == trail.lastIndex @@ -324,15 +349,13 @@ private fun CarNavBar(navigator: CarNavigator) { val weather by Weather.state.collectAsState() val weatherData = (weather as? WeatherState.Ready)?.data - 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 entries = rememberAllEntries() val pinnedEntries = remember(entries, pinnedIds) { pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } } } val frame by CarEnvironment.frame.collectAsState() + val running = rememberRunningApps() Surface(color = MaterialTheme.colorScheme.surfaceContainer) { BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) { @@ -351,21 +374,49 @@ private fun CarNavBar(navigator: CarNavigator) { NavAssistantIcon() } - pinnedEntries.forEach { entry -> - Spacer(Modifier.width(6.dp)) - entry.componentId?.let { id -> - NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) } - } - } + // Pinned shortcuts, then whatever else is open right now. A pinned app that's also + // open stays in its pinned spot, marked with a dot, instead of showing up twice. + val runningEntries = running.keys + .filter { it !in pinnedIds } + .mapNotNull { id -> entries.find { it.componentId == id } } + if (pinnedEntries.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider() + pinnedEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) } + if (pinnedEntries.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider() + runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) } - Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) { - NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media) + // Next turn first, off the map only; the player shares the space when it fits, + // and gives way to the turn when it doesn't. + val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home } + BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) { + val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + turn?.let { NavTurnPill(it) { navigator.home() } } + if (turn == null || roomForBoth) { + NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false)) + } + } } // 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() }) { + // The clock is also the notifications button: the dashboard lists them under the + // calendar and weather, and a dot here marks anything unread. + val unread by CarNotifications.unread.collectAsState() + NavChip( + navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications, + { navigator.dashboard() }, + ) { + if (unread > 0) { + Box( + Modifier + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-2).dp) + .size(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + } Column(horizontalAlignment = Alignment.End) { Text( DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)), @@ -393,49 +444,94 @@ private fun CarNavBar(navigator: CarNavigator) { } } } - Spacer(Modifier.width(10.dp)) - NavItem( - Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, padding = 8.dp, - ) { navigator.notifications() } } } } } -/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */ +/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */ @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() - } - } +private fun NavDivider() { Box( Modifier - .size(44.dp) - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, + .padding(horizontal = 10.dp) + .width(1.dp) + .height(28.dp) + .background(MaterialTheme.colorScheme.outlineVariant), + ) +} + +/** + * A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's + * already open; a long press opens its menu (pin, unpin, close). + */ +@Composable +private fun NavEntryIcon( + entry: Entry, + running: Map, + navigator: CarNavigator, + frame: CarEnvironment.Frame, +) { + val id = entry.componentId ?: return + val slot = running[id] + Box(Modifier.padding(horizontal = 3.dp)) { + NavAppIcon( + entry, + open = slot != null, + showing = slot != null && slot.isShowing(navigator.current), + onLongClick = { navigator.showAppMenu(id) }, + ) { openApp(entry, slot, navigator, frame) } + } +} + +/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */ +@Composable +private fun NavAppIcon( + entry: Entry, + open: Boolean, + showing: Boolean, + onLongClick: () -> Unit, + onClick: () -> Unit, +) { + val scheme = MaterialTheme.colorScheme + GsIconBox( + onClick, + Modifier.size(44.dp), + colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer) + else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant), + latched = showing, + onLongClick = onLongClick, ) { - icon?.let { Image(it, contentDescription, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) } + entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) } + entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) } + // Only running apps get the line along the bottom edge, the usual taskbar mark for it. + if (open) { + Box( + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 2.dp) + .size(width = 12.dp, height = 3.dp) + .clip(RoundedCornerShape(50)) + .background(scheme.primary), + ) + } } } /** 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) { - val scheme = MaterialTheme.colorScheme - Box( - Modifier - .clip(RoundedCornerShape(18.dp)) - .background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer) - .clickable(onClick = onClick) - .padding(horizontal = 10.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { content() } +private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) { + GsIconBox(onClick, colors = navColors(selected), latched = selected) { + Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() } + } +} + +/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */ +@Composable +private fun navColors(selected: Boolean): GsColors { + val s = MaterialTheme.colorScheme + return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer) + else GsColors(s.surfaceContainerHighest, s.onSurface) } /** @@ -446,32 +542,19 @@ private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable @Composable private fun NavAssistantIcon() { val state by CarAssistant.state.collectAsState() - val scheme = MaterialTheme.colorScheme val active = state.phase != CarAssistant.Phase.IDLE - Box( - Modifier - .clip(RoundedCornerShape(18.dp)) - .background(if (active) scheme.secondaryContainer else scheme.surfaceContainer) - .clickable { CarAssistant.start() } - .padding(12.dp), - contentAlignment = Alignment.Center, - ) { - MicGlyph(Modifier.size(28.dp)) + GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) { + MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp)) } } +/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */ +private val NAV_ITEM_PADDING = 8.dp + @Composable -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(padding), - contentAlignment = Alignment.Center, - ) { - Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp)) +private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) { + GsIconBox(onClick, colors = navColors(selected), latched = selected) { + Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp)) } } @@ -484,10 +567,83 @@ internal fun rememberNow(): State = produceState(System.currentTimeMillis( } } +private const val NOW_PLAYING_MAX_CHARS = 30 + +/** How long the pill shows a new song's title before settling to just the controls. */ +private const val TITLE_SHOWN_MS = 3_000L + +/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */ +private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp + +/** The next manoeuvre, as the running nav app last described it. */ +private class NextTurn(val icon: CarIcon?, val distance: String, val road: String) + /** - * 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. + * Read straight off the nav app's own template: a navigation app keeps sending its routing card + * while it's running, whether or not the map is the screen being shown. + */ +@Composable +private fun rememberNextTurn(): NextTurn? { + val status by CarServices.nav.status.collectAsState() + val template by CarServices.nav.template.collectAsState() + // Read so a change of units redraws the distance straight away, not at the next update. + CarSettings.units.collectAsState().value + if (status.phase != CarAppConnection.Phase.RUNNING) return null + val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null + if (routing.isLoading) return null + val step = routing.currentStep ?: return null + return NextTurn( + icon = step.maneuver?.icon, + distance = routing.currentDistance.display(), + road = step.road.text().ifEmpty { step.cue.text() }, + ) +} + +/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */ +@Composable +private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) { + // Same finish as every other button on the bar, in the accent colour; the text runs a step + // bigger than anything else there, since it's the one thing worth a glance mid-drive. + GsIconBox( + onClick, + Modifier.widthIn(max = 340.dp).height(44.dp), + colors = gsColors(GsTone.Primary), + ) { + Row( + Modifier.padding(NAV_ITEM_PADDING), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CarGlyph(turn.icon, Modifier.size(28.dp)) + if (turn.distance.isNotEmpty()) { + Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1) + } + if (turn.road.isNotEmpty()) { + Text( + turn.road, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** "Title - Artist" on one line, cut to [NOW_PLAYING_MAX_CHARS] with "..." when it runs long. */ +private fun nowPlayingLabel(title: String, artist: String): String { + val full = listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ") + .ifEmpty { "Nothing playing" } + return if (full.length <= NOW_PLAYING_MAX_CHARS) full + else full.take(NOW_PLAYING_MAX_CHARS - 3).trimEnd() + "..." +} + +/** + * The player in the nav bar, on every screen. Art and title open the full media screen; + * play/pause and skip work right here. The dock button moves the player into the column beside + * the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the + * column is showing lyrics instead, when the pill stays and its dock button swaps them back. */ @Composable private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) { @@ -495,83 +651,104 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: val phase by media.phase.collectAsState() val now by media.now.collectAsState() if (phase != CarMedia.Phase.READY || !now.isActive) return + // Docked beside the map, the player is already on screen - the pill steps aside for it. + if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return + // The media screen is the full player already; the pill would only repeat its controls. + if (navigator.current == CarScreen.Media) return val context = LocalContext.current val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } + val position by produceState(now.currentPosition(), now) { + while (true) { + value = now.currentPosition() + if (!now.playing) break + delay(1000) + } + } + // A new song announces itself: title, play/pause and dock for a few seconds, then the title + // goes and the pill settles to just the controls. + var showTitle by remember { mutableStateOf(true) } + LaunchedEffect(now.title, now.artist) { + showTitle = true + delay(TITLE_SHOWN_MS) + showTitle = false + } + + val scheme = MaterialTheme.colorScheme + val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer Surface( - modifier = modifier, - shape = RoundedCornerShape(50), - color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer, + modifier = modifier + .animateContentSize() + .widthIn(max = 400.dp) + .then(if (showTitle) Modifier.fillMaxWidth() else Modifier) + .height(44.dp), + shape = RoundedCornerShape(14.dp), + color = if (selected) scheme.primaryContainer else scheme.secondaryContainer, + contentColor = onPill, ) { - Row( - Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Row( - Modifier - .weight(1f, fill = false) - .clip(RoundedCornerShape(50)) - .clickable { navigator.media() }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box(Modifier.size(40.dp).clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceVariant)) { - art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } + Box { + Row( + Modifier.fillMaxHeight().padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Art (and the title while it shows) opens the full media screen. + Row( + Modifier + .then(if (showTitle) Modifier.weight(1f) else Modifier) + .fillMaxHeight() + .clip(RoundedCornerShape(10.dp)) + .clickable { navigator.media() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) { + art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } + } + if (showTitle) { + Text( + nowPlayingLabel(now.title, now.artist), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (!showTitle) { + GsIconButton( + MediaIcons.Previous, "Previous", media::previous, + enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp, + ) + } + GsIconButton( + if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, + if (now.playing) "Pause" else "Play", + media::togglePlay, + tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp, + ) + if (!showTitle) { + GsIconButton( + MediaIcons.Next, "Next", media::next, + enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp, + ) + } + GsIconButton( + MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock, + size = 36.dp, iconSize = 22.dp, + ) + } + if (now.durationMs > 0) { + // matchParentSize, so the line follows the pill's width rather than widening it. + Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) { + Box( + Modifier + .fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f)) + .height(2.dp) + .background(onPill.copy(alpha = 0.7f)), + ) + } } - Text( - now.title.ifEmpty { "Nothing playing" }, - modifier = Modifier.weight(1f, fill = false), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(44.dp)) { - Icon( - if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, - if (now.playing) "Pause" else "Play", - Modifier.size(26.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) } } } @@ -579,7 +756,7 @@ private fun LyricsGlyph(modifier: Modifier = Modifier) { /** * 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. + * bolt, so this is drawn rather than a vector asset. */ @Composable private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) { 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 7bf3350..349d03b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -1,5 +1,8 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.rememberScrollState @@ -27,10 +30,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Button -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -47,7 +47,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.SolidColor +import androidx.compose.foundation.layout.widthIn import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.addPathNodes @@ -87,63 +89,70 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { // The connection outlives this screen: the home screen's player is the same one. LaunchedEffect(app) { CarServices.openMedia(app) } - Column(Modifier.fillMaxSize()) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onExit) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back to apps") } - Text(app.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.weight(1f)) - AudioBadge(capture) + val art = rememberArt(now) + + // The art's colour behind everything, header included; the buttons keep their own. + Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) { + if (phase != CarMedia.Phase.READY) { + Column(Modifier.fillMaxSize()) { + AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) + when (phase) { + CarMedia.Phase.REJECTED -> when (rejection) { + CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice( + "${app.label} keeps its library to itself.", + "Gearslip can still control it once it has Notification access on the phone. " + + "Allow it under Settings > Notifications > Notification access.", + action = "Open on phone" to { + val app = context.applicationContext + runCatching { + app.startActivity( + Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + }, + ) + CarMedia.Rejection.REFUSED -> Notice( + "${app.label} would not let Gearslip browse it.", + "Some media apps only accept Google's own host.", + ) + } + else -> Notice("Connecting to ${app.label}…", null) + } + } + return@Surface } - when (phase) { - CarMedia.Phase.REJECTED -> when (rejection) { - CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice( - "${app.label} keeps its library to itself.", - "Gearslip can still control it once it has Notification access on the phone. " + - "Allow it under Settings > Notifications > Notification access.", - action = "Open on phone" to { - val app = context.applicationContext - runCatching { - app.startActivity( - Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } - }, - ) - CarMedia.Rejection.REFUSED -> Notice( - "${app.label} would not let Gearslip browse it.", - "Some media apps only accept Google's own host.", - ) + // Two columns, split the same way on every tab so nothing jumps when switching: the app + // and the art (or, beside a list, the compact player) on the left; the tabs across the + // top of the right column, over whatever the tab shows. + Row( + Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + Column(Modifier.weight(0.8f).fillMaxHeight()) { + AppIdentity(app, onExit) + Spacer(Modifier.height(12.dp)) + if (tab == Tab.NOW_PLAYING) { + CoverArt(art, Modifier.weight(1f).fillMaxWidth()) + } else { + CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth()) + } + AudioBadge(capture) } - CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null) - CarMedia.Phase.READY -> Row( - Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - // The art rail sits at the top, level with the tab row beside it, on every tab - - // not under a full-width tab bar the way it used to be. Transport and the - // progress bar stay on it always; only the app's extra buttons (shuffle, repeat, - // like) are exclusive to the "Now playing" tab's own content area, since those are - // the one thing that isn't already always visible here. - NowPlayingRail(now, media, Modifier.width(220.dp).fillMaxHeight()) - Column(Modifier.weight(1f).fillMaxHeight()) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING } - MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } - MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } - MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } - } - Spacer(Modifier.height(8.dp)) - Box(Modifier.weight(1f).fillMaxWidth()) { - when (tab) { - Tab.NOW_PLAYING -> NowPlayingExtras(now, media, Modifier.fillMaxSize()) - Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize()) - Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize()) - Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize()) - } + Column(Modifier.weight(1.2f).fillMaxHeight()) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING } + MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } + MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } + MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } + } + Spacer(Modifier.height(12.dp)) + Box(Modifier.weight(1f).fillMaxWidth()) { + when (tab) { + Tab.NOW_PLAYING -> NowPlayingControls(now, media, Modifier.fillMaxSize()) + Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize()) + Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize()) + Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize()) } } } @@ -151,21 +160,50 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { } } +/** + * Back, then which app this is: its own icon and its name, quieter than the song title - the + * name alone in big bold type read as one more heading fighting the track for attention. + */ +@Composable +private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) { + val icon = remember(app) { findEntry(app.component.flattenToString())?.icon } + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp) + Spacer(Modifier.width(14.dp)) + icon?.let { + Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp))) + Spacer(Modifier.width(10.dp)) + } + Text( + app.label.lowercase().replaceFirstChar { it.titlecase() }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS } /** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */ @Composable private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { - Surface( + GsIconBox( onClick = onClick, - modifier = modifier.height(48.dp), + modifier = modifier.height(44.dp), + colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer) + else GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), shape = MaterialTheme.shapes.large, - color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + latched = selected, ) { - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) - } + Text( + label, + style = MaterialTheme.typography.titleSmall, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } @@ -174,7 +212,11 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli * the current line in the accent colour; unsynced ones are plain scrolling text. */ @Composable -internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) { +internal fun LyricsPanel( + now: NowPlaying, + modifier: Modifier, + lineStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.titleLarge, +) { val context = LocalContext.current val lyrics by produceState(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) { value = LyricsState.Loading @@ -208,7 +250,7 @@ internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) { itemsIndexed(lines) { index, line -> Text( line.text.ifEmpty { " " }, - style = MaterialTheme.typography.titleLarge, + style = lineStyle, fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal, color = when { !state.result.synced -> MaterialTheme.colorScheme.onSurface @@ -255,115 +297,97 @@ private fun Notice(title: String, detail: String?, action: Pair Un } action?.let { (label, run) -> Spacer(Modifier.height(12.dp)) - androidx.compose.material3.FilledTonalButton(onClick = run) { Text(label) } + GsButton(onClick = run, tone = GsTone.Tonal) { Text(label) } } } } -/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */ -private fun formatDuration(ms: Long): String { - val totalSeconds = (ms / 1000).coerceAtLeast(0) - val hours = totalSeconds / 3600 - val minutes = (totalSeconds % 3600) / 60 - val seconds = totalSeconds % 60 - return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds) -} - // --- now playing ----------------------------------------------------------------------------- /** - * The art rail beside every tab: art, title, transport and the seek bar - always visible no - * matter which tab is open. Only the app's extra buttons (shuffle, repeat, like) are held back - * for [NowPlayingExtras], since those are the one thing that isn't already always shown here. + * The now-playing view on its own, for places with no tabs around it (an app's own + * now-playing template): the cover on the left, [NowPlayingControls] on the right. */ @Composable -internal fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) { - val context = LocalContext.current - val art by produceState(now.art, now.art, now.artUri) { - value = now.art ?: MediaArt.load(context, now.artUri) +internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) { + Row( + modifier.padding(horizontal = 24.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight()) + NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight()) } - var position by remember { mutableLongStateOf(0L) } - LaunchedEffect(now) { - while (true) { - position = now.currentPosition() - kotlinx.coroutines.delay(500) +} + +/** The cover, as big a square as its space allows, centred in it. */ +@Composable +private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) { + BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 6.dp, + modifier = Modifier.size(minOf(maxWidth, maxHeight)), + ) { + art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } } } +} - // Whether the progress bar (and its duration text) is showing depends on whether the app has - // reported a duration yet, which can arrive a beat after everything else - so this rail's - // content height isn't fixed. A plain fillMaxHeight Column just clips whatever doesn't fit - // once that row appears; scrolling means it's always reachable instead. - BoxWithConstraints(modifier) { - // Off the space actually available rather than a fixed dp value, so it doesn't overrun a - // short car frame the way a flat size did on anything shorter than a phone screen. - val artSize = (minOf(maxWidth, maxHeight * 0.45f) * 1.2f).coerceAtLeast(96.dp) - Column(Modifier.verticalScroll(rememberScrollState())) { - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.size(artSize), - ) { - art?.let { - Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop) - } - } - Spacer(Modifier.height(16.dp)) +/** + * Everything about the track and every control for it, read top to bottom: title and artist, + * the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons. + */ +@Composable +private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) { + Column( + modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically), + ) { + Column { + Text( + now.title.ifEmpty { "Nothing playing" }, + style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, + maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + Text( + listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "), + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) } + } + PlayerProgress(now, media, Modifier.fillMaxWidth()) + TransportRow(now, media, skip = 60.dp, modifier = Modifier.fillMaxWidth()) + PlayModeRow(now, media, size = 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2) + } +} + +/** + * The player beside the browse list, the queue and the lyrics: no art, which the list needs the + * room for, just what's playing, the seek bar and the controls. + */ +@Composable +private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) { + Column( + modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column { Text( now.title.ifEmpty { "Nothing playing" }, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis, ) Text( - listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "), + now.artist, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) } - Spacer(Modifier.height(16.dp)) - - 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.fillMaxWidth().padding(vertical = 4.dp), - ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(Modifier.height(12.dp)) - } - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) { - Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp)) - } - FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) { - Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp)) - } - IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) { - Icon(MediaIcons.Next, "Next", Modifier.size(32.dp)) - } - } - } - } -} - -/** - * The "Now playing" tab's own content: just the app's extra buttons (shuffle, repeat, like), by - * name - their icons are resources inside the app's own package, so there's nothing to draw but - * the label. Transport and progress already live on [NowPlayingRail], visible on every tab. - */ -@Composable -private fun NowPlayingExtras(now: NowPlaying, media: CarMedia, modifier: Modifier) { - Column(modifier, verticalArrangement = Arrangement.Center) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - now.custom.take(3).forEach { action -> - TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) } - } } + PlayerProgress(now, media, Modifier.fillMaxWidth()) + TransportRow(now, media, skip = 52.dp, modifier = Modifier.fillMaxWidth()) + PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth()) } } @@ -378,7 +402,7 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me if (media.canGoUp || browse.trail.isNotEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { if (media.canGoUp) { - IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") } + GsIconButton(Icons.Filled.ArrowBack, "Up", media::up) } browse.trail.lastOrNull()?.let { Text( @@ -408,7 +432,7 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me /** * Whatever the app has queued up, starting right after the track that's playing now - not the - * whole queue from the top, which would just repeat what [NowPlayingRail] already shows. Apps + * whole queue from the top, which would just repeat what the player beside it already shows. Apps * that never publish a queue (many don't) show an empty notice rather than a permanently blank tab. */ @Composable @@ -482,7 +506,7 @@ private fun EntryRow(entry: MediaEntry, media: CarMedia) { } } if (entry.browsable && entry.playable) { - IconButton(onClick = { media.playAll(entry) }) { Icon(Icons.Filled.PlayArrow, "Play") } + GsIconButton(Icons.Filled.PlayArrow, "Play", { media.playAll(entry) }) } if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) } @@ -497,8 +521,13 @@ internal object MediaIcons { val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z") val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z") val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z") + val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z") + val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z") + val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z") + /** A screen with a filled right-hand panel: "put the player beside the map". */ + val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd) - private fun icon(name: String, path: String) = ImageVector.Builder( + private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder( name, 24.dp, 24.dp, 24f, 24f, - ).addPath(addPathNodes(path), fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build() + ).addPath(addPathNodes(path), pathFillType = fillType, fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt new file mode 100644 index 0000000..5d8dee5 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MessagesScreen.kt @@ -0,0 +1,181 @@ +package app.seb3thehacker.gearslip.car + +import app.seb3thehacker.gearslip.car.theme.* +import android.content.Intent +import android.provider.Settings +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 app.seb3thehacker.gearslip.notify.CarNotification +import app.seb3thehacker.gearslip.notify.CarNotifications +import app.seb3thehacker.gearslip.notify.ChatLine +import java.text.DateFormat +import java.util.Date + +/** Lines a collapsed conversation shows; tapping it shows the rest the app sent. */ +private const val COLLAPSED_LINES = 2 + +/** + * A messaging app's recent conversations, the way Android Auto shows them: the app has no car + * screen of its own, so each thread comes from the notification it has on the phone, newest + * first, with reply and mark-as-read when the app offers them. + */ +@Composable +fun MessagesScreen(packageName: String) { + val history by CarNotifications.history.collectAsState() + val listening by CarNotifications.listening.collectAsState() + val navigator = LocalCarNavigator.current + val context = LocalContext.current.applicationContext + val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) } + val label = entry?.label ?: history.firstOrNull { it.packageName == packageName }?.appLabel ?: "Messages" + val threads = history.filter { it.packageName == packageName } + + LaunchedEffect(history) { CarNotifications.dismissPopupFrom(packageName) } + + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back) + entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) } + Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + } + + if (!listening) { + AccessBanner { + context.startActivity( + Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + } + + if (threads.isEmpty()) { + Text( + "No recent messages. New $label messages appear here as they arrive on the phone.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + items(threads, key = { it.key }) { n -> + ConversationCard( + n, + onReply = { navigator.notifications(replyTo = n.key) }, + onMarkRead = { CarNotifications.markThreadRead(context, n) }, + ) + } + } + } + } +} + +@Composable +private fun ConversationCard(n: CarNotification, onReply: () -> Unit, onMarkRead: () -> Unit) { + var expanded by rememberSaveable(n.key) { mutableStateOf(false) } + // An app that posts plain notifications instead of a conversation still gets one line. + val lines = n.lines.ifEmpty { listOf(ChatLine(n.title.ifBlank { n.appLabel }, n.text.ifBlank { n.title }, n.postedAt)) } + val shown = if (expanded) lines else lines.takeLast(COLLAPSED_LINES) + val dim = n.read || n.replied + + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + .clickable(enabled = lines.size > COLLAPSED_LINES) { expanded = !expanded }, + ) { + Row( + Modifier.padding(14.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + NotificationIcon(n, size = 52.dp) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + n.title.ifBlank { n.appLabel }, + style = MaterialTheme.typography.titleLarge, + fontWeight = if (dim) FontWeight.Medium else FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Text( + " · ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(n.postedAt))}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (!expanded && lines.size > COLLAPSED_LINES) { + Text( + "${lines.size - COLLAPSED_LINES} earlier", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + shown.forEach { MessageLine(it, showSender = n.isGroup) } + } + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) { + when { + n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary) + n.reply != null -> GsButton(onClick = onReply, tone = GsTone.Tonal) { Text("Reply") } + } + if (n.markRead != null) { + GsButton(onClick = onMarkRead, tone = GsTone.Neutral) { Text("Mark read") } + } + } + } + } +} + +@Composable +private fun MessageLine(line: ChatLine, showSender: Boolean) { + val who = when { + line.sender == null -> "You: " + showSender -> "${line.sender}: " + else -> "" + } + Text( + who + line.text, + style = MaterialTheme.typography.bodyLarge, + color = if (line.sender == null) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt new file mode 100644 index 0000000..d74c4ac --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt @@ -0,0 +1,239 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.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.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.vector.ImageVector +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.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.car.theme.GsButton +import app.seb3thehacker.gearslip.car.theme.GsTone +import app.seb3thehacker.gearslip.host.CarAppConnection +import app.seb3thehacker.gearslip.media.CarMedia + +/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */ +internal const val BUILT_IN_PREFIX = "gearslip:" + +/** One of Gearslip's own apps: a screen, with nothing running behind it once you leave. */ +internal class BuiltInApp( + val id: String, + val label: String, + val glyph: ImageVector, + val screen: CarScreen, + val open: (CarNavigator) -> Unit, +) + +internal object BuiltInApps { + val Web = BuiltInApp("web", "Web", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") } + val ScreenSharing = BuiltInApp("phone", "Screen sharing", Icons.Filled.Share, CarScreen.App("phone")) { it.open("phone") } + val Phone = BuiltInApp("dialer", "Phone", Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") } + val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() } + val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() } + + val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings) + val entries: List = all.map { Entry(it.label, icon = null, builtIn = it) } +} + +/** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */ +@Composable +internal fun rememberVehicleDataShown(): Boolean { + val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState() + val sensors by CarSettings.carSensorsEnabled.collectAsState() + return experimental && sensors +} + +/** + * Where an open app lives - decides where tapping it goes and how it closes. Gearslip's own + * apps have no connection behind them, so [SCREEN] is simply "on screen right now". + */ +internal enum class RunningSlot { NAV, BROWSE, MEDIA, SCREEN } + +/** + * Apps open right now, as component id to where they live, in the order the nav bar shows them: + * the map, then a browsed templated app, then the media app, then whichever built-in app is on + * screen. The map and a browsed app count from the moment they start connecting until they're + * closed. The media app counts while it has something loaded to play - the same rule the nav + * bar's player follows, so the two always show up together - or while its own screen is up. + */ +@Composable +internal fun rememberRunningApps(): Map { + val navStatus by CarServices.nav.status.collectAsState() + val browseStatus by CarServices.browse.status.collectAsState() + val mediaApp by CarServices.mediaApp.collectAsState() + val mediaPhase by CarServices.media.phase.collectAsState() + val now by CarServices.media.now.collectAsState() + val current = LocalCarNavigator.current.current + + fun CarAppConnection.liveId(phase: CarAppConnection.Phase) = + connectedComponent?.flattenToString()?.takeIf { + phase == CarAppConnection.Phase.BINDING || + phase == CarAppConnection.Phase.HANDSHAKE || + phase == CarAppConnection.Phase.RUNNING + } + + return buildMap { + CarServices.nav.liveId(navStatus.phase)?.let { put(it, RunningSlot.NAV) } + CarServices.browse.liveId(browseStatus.phase)?.let { put(it, RunningSlot.BROWSE) } + val mediaLive = when (mediaPhase) { + CarMedia.Phase.CONNECTING -> true + CarMedia.Phase.READY -> now.isActive || current == CarScreen.Media + else -> false + } + mediaApp?.component?.flattenToString()?.takeIf { mediaLive }?.let { put(it, RunningSlot.MEDIA) } + BuiltInApps.all.firstOrNull { it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) } + (current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) } + } +} + +/** The screen an open app is showing on, so its nav bar icon can stay pressed in while it's up. */ +internal fun RunningSlot.isShowing(current: CarScreen): Boolean = when (this) { + RunningSlot.NAV -> current == CarScreen.Home + RunningSlot.BROWSE -> current == CarScreen.Browse + RunningSlot.MEDIA -> current == CarScreen.Media + RunningSlot.SCREEN -> true +} + +/** + * Brings an app up: back to its screen if it's already open - never a reconnect, which would + * throw away the route or restart the session - otherwise a fresh launch. + */ +internal fun openApp(entry: Entry, slot: RunningSlot?, navigator: CarNavigator, frame: CarEnvironment.Frame) { + when (slot) { + RunningSlot.NAV -> navigator.home() + RunningSlot.BROWSE -> navigator.browse(entry.label) + RunningSlot.MEDIA -> navigator.media() + RunningSlot.SCREEN -> Unit + null -> launchEntry(entry, navigator, frame) + } +} + +internal fun closeApp(slot: RunningSlot, navigator: CarNavigator) { + when (slot) { + RunningSlot.NAV -> CarServices.stopNav() + RunningSlot.BROWSE -> CarServices.stopBrowse() + RunningSlot.MEDIA -> CarServices.closeMedia() + RunningSlot.SCREEN -> Unit + } + if (slot != RunningSlot.NAV && slot.isShowing(navigator.current)) navigator.back() +} + +/** + * What a long press on an app offers - pin it to or unpin it from the nav bar, and close it if + * it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that + * dismisses it. Drawn in the car UI's own layout rather than a system popup window, which the + * car's virtual display has no reason to host well. + */ +@Composable +internal fun AppMenu(navigator: CarNavigator, running: Map) { + val id = navigator.appMenu ?: return + val entry = findEntry(id) + if (entry == null) { + navigator.dismissAppMenu() + return + } + val pinned by CarSettings.pinnedApps.collectAsState() + val isPinned = id in pinned + val barFull = !isPinned && pinned.size >= CarSettings.MAX_PINNED + val slot = running[id] + + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = navigator::dismissAppMenu, + ), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + // Swallows taps on the card itself, so only the scrim around it dismisses. + modifier = Modifier + .widthIn(max = 420.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + entry.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) } + entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) } + Text( + entry.label, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 14.dp), + ) + } + GsButton( + onClick = { + CarSettings.togglePin(id) + navigator.dismissAppMenu() + }, + tone = GsTone.Tonal, + enabled = !barFull, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + when { + isPinned -> "Unpin from nav bar" + barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)" + else -> "Pin to nav bar" + }, + style = MaterialTheme.typography.titleMedium, + ) + } + if (slot != null) { + GsButton( + onClick = { + closeApp(slot, navigator) + navigator.dismissAppMenu() + }, + tone = GsTone.Danger, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { Text("Close app", style = MaterialTheme.typography.titleMedium) } + } + GsButton( + onClick = navigator::dismissAppMenu, + tone = GsTone.Neutral, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { Text("Cancel", style = MaterialTheme.typography.titleMedium) } + } + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt index f01a27e..a1b4996 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt @@ -1,9 +1,9 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* import android.content.Intent import android.provider.Settings 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 @@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -23,10 +21,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.Button -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -35,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -52,34 +48,40 @@ import kotlinx.coroutines.delay import java.text.DateFormat import java.util.Date -/** The notification history, with a reply pane for anything the sending app lets us answer. */ +/** + * Replying to one notification. The list itself lives on the dashboard (see + * [NotificationsSection]); with nothing to reply to, this is just the dashboard. + */ @Composable fun NotificationsScreen(replyTo: String?) { val history by CarNotifications.history.collectAsState() - var replying by remember(replyTo) { mutableStateOf(replyTo) } - - // Anything arriving while this is open is being looked at, so it never counts as unread. - LaunchedEffect(history) { CarNotifications.markRead() } - - val target = history.firstOrNull { it.key == replying && it.reply != null } + val navigator = LocalCarNavigator.current + val target = history.firstOrNull { it.key == replyTo && it.reply != null } if (target != null) { - ReplyPane(target, onDone = { replying = null }) + ReplyPane(target, onDone = navigator::back) } else { - NotificationList(history, onReply = { replying = it.key }) + CarDashboardScreen() } } +/** + * The notification history, under the calendar on the dashboard. Replying opens its own pane. + * Anything arriving while this is on screen is being looked at, so it never counts as unread. + */ @Composable -private fun NotificationList(history: List, onReply: (CarNotification) -> Unit) { +fun NotificationsSection() { + val history by CarNotifications.history.collectAsState() val listening by CarNotifications.listening.collectAsState() + val navigator = LocalCarNavigator.current val context = LocalContext.current.applicationContext + LaunchedEffect(history) { CarNotifications.markRead() } - Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( "Notifications", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f), ) if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") } @@ -94,22 +96,21 @@ private fun NotificationList(history: List, onReply: (CarNotifi } if (history.isEmpty()) { - Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { - Text( - "Nothing yet. New notifications from the phone will show up here.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text( + "Nothing yet. New notifications from the phone will show up here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } else { - LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(history, key = { it.key }) { NotificationRow(it, onReply) } + history.forEach { n -> + key(n.key) { NotificationRow(n) { navigator.notifications(replyTo = it.key) } } } } } } @Composable -private fun AccessBanner(onOpen: () -> Unit) { +internal fun AccessBanner(onOpen: () -> Unit) { Surface( shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.errorContainer, @@ -127,7 +128,7 @@ private fun AccessBanner(onOpen: () -> Unit) { style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) - Button(onClick = onOpen) { Text("Open") } + GsButton(onClick = onOpen) { Text("Open") } } } } @@ -160,14 +161,14 @@ private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Un } when { n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary) - n.reply != null -> FilledTonalButton(onClick = { onReply(n) }) { Text("Reply") } + n.reply != null -> GsButton(onClick = { onReply(n) }, tone = GsTone.Tonal) { Text("Reply") } } } } } @Composable -private fun NotificationIcon(n: CarNotification, size: androidx.compose.ui.unit.Dp = 44.dp) { +internal fun NotificationIcon(n: CarNotification, size: androidx.compose.ui.unit.Dp = 44.dp) { val icon = n.icon if (icon != null) { Image(icon.asImageBitmap(), contentDescription = null, modifier = Modifier.size(size).clip(RoundedCornerShape(10.dp))) @@ -194,7 +195,7 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } + GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone) Surface( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainerHighest, @@ -250,7 +251,7 @@ fun NotificationPopup(modifier: Modifier = Modifier) { modifier = modifier .padding(top = 8.dp) .fillMaxWidth(0.7f) - .clickable { navigator.notifications() }, + .clickable { navigator.dashboard() }, ) { Row( Modifier.padding(12.dp), @@ -268,11 +269,9 @@ fun NotificationPopup(modifier: Modifier = Modifier) { } } if (n.reply != null) { - FilledTonalButton(onClick = { navigator.notifications(replyTo = n.key) }) { Text("Reply") } - } - IconButton(onClick = { CarNotifications.dismissPopup(n.id) }) { - Icon(Icons.Filled.Close, contentDescription = "Dismiss") + GsButton(onClick = { navigator.notifications(replyTo = n.key) }, tone = GsTone.Tonal) { Text("Reply") } } + GsIconButton(Icons.Filled.Close, "Dismiss", { CarNotifications.dismissPopup(n.id) }) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt index 094c00d..3c80b1d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,7 +15,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -96,19 +96,18 @@ private fun TouchRelayWarning() { style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) - Button(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") } + GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") } } } } @Composable private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) { - Surface( + GsIconBox( onClick = onClick, modifier = Modifier.height(120.dp), + colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, ) { Column( Modifier.fillMaxSize().padding(8.dp), diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt index eda9481..bf16606 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* import android.Manifest import android.content.Context import android.content.Intent @@ -20,17 +21,14 @@ 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.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call -import androidx.compose.material3.Button -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -102,23 +100,27 @@ fun PhoneDialerScreen() { var tab by remember { mutableStateOf(TAB_CONTACTS) } Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - "Phone", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(vertical = 4.dp), - ) + // The tabs share the title's row, so the list and keypad get the height back. + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + "Phone", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + if (hasPermissions) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS } + DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD } + } + } + } if (!hasPermissions) { MissingPermission(context) return@Column } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.weight(1f)) { tab = TAB_CONTACTS } - DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.weight(1f)) { tab = TAB_KEYPAD } - } - Box(Modifier.weight(1f).fillMaxWidth()) { if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context) } @@ -134,7 +136,7 @@ private fun MissingPermission(context: Context) { style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - FilledTonalButton(onClick = { + GsButton(tone = GsTone.Tonal, onClick = { context.startActivity( Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)), ) @@ -144,16 +146,15 @@ private fun MissingPermission(context: Context) { @Composable private fun DialerTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { - Surface( + GsIconBox( onClick = onClick, - modifier = modifier.height(52.dp), + modifier = modifier.height(44.dp), + colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer) + else GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), shape = MaterialTheme.shapes.large, - color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + latched = selected, ) { - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) - } + Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) } } @@ -193,19 +194,17 @@ private fun Keypad(context: Context) { } } Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - FilledTonalButton( + GsButton( onClick = { if (number.isNotEmpty()) number = number.dropLast(1) }, + tone = GsTone.Tonal, enabled = number.isNotEmpty(), modifier = Modifier.weight(1f).fillMaxWidth(), - ) { Text("⌫") } - FilledIconButton( + ) { Text("⌫", style = MaterialTheme.typography.titleLarge) } + GsIconBox( onClick = { placeCall(context, number) }, + colors = gsColors(GsTone.Primary), enabled = number.isNotEmpty(), modifier = Modifier.weight(1f).fillMaxWidth(), - colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), ) { Icon(Icons.Filled.Call, "Call") } } } @@ -214,15 +213,13 @@ private fun Keypad(context: Context) { @Composable private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) { - Surface( - onClick = onClick, - modifier = modifier, + GsIconBox( + onClick, + modifier, + colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - Text(digit, style = MaterialTheme.typography.titleLarge) - } + Text(digit, style = MaterialTheme.typography.titleLarge) } } @@ -315,10 +312,10 @@ private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismis Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { - OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f).height(56.dp)) { + GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) { Text("Cancel", style = MaterialTheme.typography.titleMedium) } - Button(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) { + GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) { Text("Call", style = MaterialTheme.typography.titleMedium) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt new file mode 100644 index 0000000..987b89c --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt @@ -0,0 +1,236 @@ +package app.seb3thehacker.gearslip.car + +import android.media.session.PlaybackState +import android.support.v4.media.session.PlaybackStateCompat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import app.seb3thehacker.gearslip.media.ArtColor +import app.seb3thehacker.gearslip.media.MediaArt +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import app.seb3thehacker.gearslip.car.theme.GsIconBox +import app.seb3thehacker.gearslip.car.theme.GsIconButton +import app.seb3thehacker.gearslip.car.theme.GsSeekBar +import app.seb3thehacker.gearslip.car.theme.GsTone +import app.seb3thehacker.gearslip.car.theme.gsColors +import app.seb3thehacker.gearslip.media.CarMedia +import app.seb3thehacker.gearslip.media.CustomAction +import app.seb3thehacker.gearslip.media.NowPlaying +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/* + * The pieces every player on the car screen is built from - the media screen, the column beside + * the map - so a button looks and behaves the same wherever it turns up. + */ + +/** The seek bar, with elapsed and total time under it when [showTimes]. */ +@Composable +internal fun PlayerProgress(now: NowPlaying, media: CarMedia, modifier: Modifier = Modifier, showTimes: Boolean = true) { + if (now.durationMs <= 0) return + var position by remember { mutableLongStateOf(now.currentPosition()) } + LaunchedEffect(now) { + while (true) { + position = now.currentPosition() + if (!now.playing) break + delay(500) + } + } + Column(modifier) { + GsSeekBar( + fraction = position.toFloat() / now.durationMs, + onSeek = { media.seek((it * now.durationMs).toLong()) }, + enabled = now.canDo(PlaybackState.ACTION_SEEK_TO), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) + if (showTimes) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */ +internal fun formatDuration(ms: Long): String { + val totalSeconds = (ms / 1000).coerceAtLeast(0) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds) +} + +/** Previous, play/pause, next. [skip] sizes the outer two; play is a step bigger. */ +@Composable +internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier) { + val play = skip * 1.25f + Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { + GsIconButton( + MediaIcons.Previous, "Previous", media::previous, + enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape, + ) + GsIconButton( + if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, + if (now.playing) "Pause" else "Play", + media::togglePlay, + tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape, + ) + GsIconButton( + MediaIcons.Next, "Next", media::next, + enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = skip, iconSize = skip * 0.58f, shape = CircleShape, + ) + } +} + +/** + * Like, shuffle and repeat, then up to [maxOther] of the app's other buttons, all icon-only. One + * the app doesn't support stays on screen greyed out, so the row never shifts between apps. + * [trailing] adds buttons of the caller's own at the end (the side column's lyrics button). + */ +@Composable +internal fun PlayModeRow( + now: NowPlaying, + media: CarMedia, + size: Dp, + modifier: Modifier = Modifier, + maxOther: Int = 0, + trailing: @Composable RowScope.() -> Unit = {}, +) { + val modes by media.modes.collectAsState() + val liked = now.liked + val shuffleKnown = modes.shuffle != PlaybackStateCompat.SHUFFLE_MODE_INVALID + val repeatKnown = modes.repeat != PlaybackStateCompat.REPEAT_MODE_INVALID + val shuffleOn = shuffleKnown && modes.shuffle != PlaybackStateCompat.SHUFFLE_MODE_NONE + val repeatOn = repeatKnown && modes.repeat != PlaybackStateCompat.REPEAT_MODE_NONE + val icon = size * 0.52f + Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { + GsIconButton( + if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, + if (liked == true) "Unlike" else "Like", + media::toggleLike, + enabled = liked != null, + tone = if (liked == true) GsTone.Primary else GsTone.Neutral, + size = size, iconSize = icon, + ) + GsIconButton( + MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on", + media::toggleShuffle, + enabled = shuffleKnown || now.shuffleAction != null, + tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral, + size = size, iconSize = icon, + ) + GsIconButton( + if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat, + "Repeat", + media::cycleRepeat, + enabled = repeatKnown || now.repeatAction != null, + tone = if (repeatOn) GsTone.Primary else GsTone.Neutral, + size = size, iconSize = icon, + ) + now.otherActions.take(maxOther).forEach { CustomActionButton(it, media, size) } + trailing() + } +} + +/** + * One of the app's own buttons, drawn with the app's own icon - it lives in the app's package, + * not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load. + */ +@Composable +private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp) { + val icon = rememberActionIcon(action) + GsIconBox( + onClick = { media.custom(action) }, + modifier = Modifier.size(size), + colors = gsColors(GsTone.Neutral), + ) { + if (icon != null) { + Icon(icon, action.name, Modifier.size(size * 0.52f)) + } else { + Text(action.name, style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun rememberActionIcon(action: CustomAction): ImageBitmap? { + val context = LocalContext.current + val app by CarServices.mediaApp.collectAsState() + val pkg = app?.component?.packageName + val icon by produceState(null, pkg, action.icon) { + value = if (pkg == null || action.icon == 0) null else withContext(Dispatchers.IO) { + runCatching { + context.packageManager.getResourcesForApplication(pkg) + .getDrawable(action.icon, null).toBitmap(96, 96).asImageBitmap() + }.getOrNull() + } + } + return icon +} + +/** The track's art, from the session's bitmap or, failing that, its art URI. */ +@Composable +internal fun rememberArt(now: NowPlaying): android.graphics.Bitmap? { + val context = LocalContext.current + val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } + return art +} + +/** + * A background taken from the album art's main colour, in place of [fallback]. Only the hue and a + * capped amount of its saturation come from the art; the lightness stays close to the theme's + * own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same + * on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than + * jumping. + */ +@Composable +internal fun rememberArtColor( + art: android.graphics.Bitmap?, + fallback: Color, + darkLightness: Float = 0.2f, + lightLightness: Float = 0.86f, +): Color { + val dark = fallback.luminance() < 0.5f + val tint by produceState(null, art) { + value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } } + } + val target = tint?.let { + if (dark) Color.hsl(it.hue, it.saturation.coerceIn(0.3f, 0.5f), darkLightness) + else Color.hsl(it.hue, it.saturation.coerceIn(0.35f, 0.55f), lightLightness) + } ?: fallback + return animateColorAsState(target, tween(600), label = "art colour").value +} 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 89eb951..5551709 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt @@ -1,9 +1,9 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* 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 @@ -23,7 +23,6 @@ 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 import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -274,26 +273,20 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - 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), - ) - } + // 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. + GsIconButton( + Icons.Filled.Close, + "Stop navigating", + { CarServices.nav.stopNavigating() }, + colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), + size = 40.dp, + iconSize = 20.dp, + shape = CircleShape, + ) Spacer(Modifier.width(10.dp)) Column { if (time.isNotEmpty()) { 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 591270d..1c20583 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt @@ -1,7 +1,6 @@ package app.seb3thehacker.gearslip.car -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -403,15 +402,12 @@ private fun TabContent(template: TabTemplate, modifier: Modifier) { template.headerAction?.let { ActionButton(it) } template.tabs.forEach { tab -> val selected = tab.contentId == active - Surface( - color = if (selected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurface, + GsIconBox( + onClick = { template.tabCallbackDelegate.tabSelected(tab.contentId) }, + colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer) + else GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface), shape = RoundedCornerShape(20.dp), - modifier = Modifier.clickable { - template.tabCallbackDelegate.tabSelected(tab.contentId) - }, + latched = selected, ) { Row( Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -553,7 +549,7 @@ private fun SectionRows(section: Section<*>, cacheKey: String) { /** * The template itself carries only a header - real Android Auto hosts draw this screen from the * app's MediaSession instead, which is exactly what [CarServices.media] already does for the - * media-app launcher and the bottom bar. Reusing [NowPlayingRail] here rather than a "not + * media-app launcher and the bottom bar. Reusing [NowPlayingPage] here rather than a "not * supported" placeholder means an app whose own now-playing template Gearslip lands on (like * Spotify's "Liked Songs" screen) still gets working transport controls. */ @@ -568,7 +564,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi template.header?.endHeaderActions.orEmpty(), ) if (now.title.isNotEmpty()) { - NowPlayingRail(now, media, Modifier.weight(1f).fillMaxWidth()) + NowPlayingPage(now, media, Modifier.weight(1f).fillMaxWidth()) } else { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Text( 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 bdf9da1..c621949 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.material3.LocalContentColor @@ -300,15 +301,17 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi } val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM - Surface( - color = background, - contentColor = content, - shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp), - modifier = modifier.clickable(enabled = action.isEnabled) { + GsIconBox( + onClick = { if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed() else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn) else action.onClickDelegate?.click("action") }, + modifier = modifier, + colors = GsColors(background, content), + enabled = action.isEnabled, + shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp), + latched = active, ) { if (large && title.isNotEmpty()) { // The label sits dead centre on the button; the icon hangs off the left edge, so the @@ -368,13 +371,14 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) { val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary val hasGlyph = action.icon != null - Surface( + GsIconBox( + onClick = { fire() }, + modifier = modifier.fillMaxWidth(), // 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, + colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, content), + enabled = action.isEnabled, 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. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt deleted file mode 100644 index 5384a71..0000000 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt +++ /dev/null @@ -1,92 +0,0 @@ -package app.seb3thehacker.gearslip.car - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.dp -import kotlin.math.PI -import kotlin.math.sin - -/** - * A progress bar whose played part is a wave, and which flattens out while paused. - * - * The wave runs only while [playing], so a paused player costs the encoder nothing. Tapping or - * dragging seeks; the position is committed on release rather than on every pixel of a drag, - * because each commit is a request to the media app. - */ -@Composable -fun WavyProgress( - fraction: Float, - playing: Boolean, - onSeek: (Float) -> Unit, - modifier: Modifier = Modifier, -) { - val played = MaterialTheme.colorScheme.primary - val remaining = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - val amplitude by animateFloatAsState(if (playing) 1f else 0f, tween(400), label = "wave") - val phase = if (playing) { - rememberInfiniteTransition(label = "phase").animateFloat( - 0f, (2 * PI).toFloat(), - infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart), - label = "phase", - ).value - } else 0f - - var dragging by remember { mutableStateOf(null) } - val shown = (dragging ?: fraction).coerceIn(0f, 1f) - - Canvas( - modifier - .fillMaxWidth() - .height(32.dp) - .pointerInput(Unit) { detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) } } - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragging = (it.x / size.width).coerceIn(0f, 1f) }, - onDragEnd = { dragging?.let(onSeek); dragging = null }, - onDragCancel = { dragging = null }, - ) { change, _ -> dragging = (change.position.x / size.width).coerceIn(0f, 1f) } - }, - ) { - val stroke = 4.dp.toPx() - val mid = size.height / 2 - val end = size.width * shown - val wavelength = 22.dp.toPx() - val amp = 3.5.dp.toPx() * amplitude - - // Unplayed part: a straight line, so the wave reads as "this much is done". - drawLine(remaining, Offset(end, mid), Offset(size.width, mid), stroke, StrokeCap.Round) - - val wave = Path().apply { - moveTo(0f, mid + amp * sin(phase.toDouble()).toFloat()) - var x = 0f - while (x < end) { - x = minOf(end, x + 2f) - lineTo(x, mid + amp * sin(2 * PI * x / wavelength + phase).toFloat()) - } - } - drawPath(wave, played, style = Stroke(stroke, cap = StrokeCap.Round)) - drawLine(played, Offset(end, mid - 10.dp.toPx()), Offset(end, mid + 10.dp.toPx()), stroke, StrokeCap.Round) - } -} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt index 5d7013b..5c9ea2d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/Weather.kt @@ -136,7 +136,7 @@ object Weather { // --- network -------------------------------------------------------------------------- private fun fetch(context: Context, location: Location): WeatherData { - val imperial = Locale.getDefault().country in setOf("US", "LR", "MM") + val imperial = CarSettings.imperial() val url = URL( "https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" + "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt new file mode 100644 index 0000000..eca8a87 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsControls.kt @@ -0,0 +1,326 @@ +package app.seb3thehacker.gearslip.car.theme + +import android.os.SystemClock +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.translate +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** What a control is for, which picks its colours from the Material scheme. */ +enum class GsTone { Primary, Tonal, Neutral, Outline, Danger } + +data class GsColors(val container: Color, val content: Color, val border: Color = Color.Unspecified) + +@Composable +fun gsColors(tone: GsTone): GsColors { + val s = MaterialTheme.colorScheme + return when (tone) { + GsTone.Primary -> GsColors(s.primary, s.onPrimary) + GsTone.Tonal -> GsColors(s.secondaryContainer, s.onSecondaryContainer) + GsTone.Neutral -> GsColors(s.surfaceContainerHighest, s.onSurfaceVariant) + GsTone.Outline -> GsColors(Color.Transparent, s.primary, s.outline) + GsTone.Danger -> GsColors(s.error, s.onError) + } +} + +@Composable +fun gsShape(): Shape = RoundedCornerShape(gsLook().corner) + +/** The current theme's control look, swapped for its light-mode variant on a light UI. */ +@Composable +fun gsLook(): ControlLook { + val look = LocalGsTheme.current.control + val light = look.light ?: return look + return if (MaterialTheme.colorScheme.surface.luminance() > 0.5f) light else look +} + +/** + * Paints a control's container in the current theme's finish. Transparent containers stay + * transparent in every theme. Use directly for custom-shaped tappables; [GsButton] and + * [GsIconButton] already apply it. [latched] holds a control down - a selected option, the current + * screen's nav button - in themes that have a pressed-in look. + */ +@Composable +fun Modifier.gsSurface( + container: Color, + shape: Shape = gsShape(), + border: Color = Color.Unspecified, + pressed: Boolean = false, + latched: Boolean = false, +): Modifier { + val look = gsLook() + val down = pressed || latched + // The keycap's face sinks onto its base while pressed, and stays down while latched: the base + // shrinks to nothing and the face moves down by the same amount, so the control's overall size + // never changes. + val lipNow by animateDpAsState(if (down) 0.dp else look.lip, tween(if (pressed) 50 else 110), label = "keycap") + // Tactile: the drop shadow collapses and the face drops by [ControlLook.sink]. + val lift by animateFloatAsState(if (down) 0f else 1f, tween(if (pressed) 50 else 140), label = "lift") + val sinkNow by animateDpAsState(if (down) look.sink else 0.dp, tween(if (pressed) 50 else 140), label = "sink") + var m = this + if (container.alpha > 0f && look.shadow > 0.dp) { + m = m.drawBehind { drawSoftShadow(shape, look.shadow.toPx(), look.shadowAlpha * lift, lift) } + } + if (look.sink > 0.dp) m = m.offset(y = sinkNow) + if (container.alpha > 0f && look.lip > 0.dp) { + val base = lerp(container, Color.Black, 0.35f) + m = m + .padding(top = look.lip - lipNow, bottom = lipNow) + .drawBehind { + translate(top = lipNow.toPx()) { + drawOutline(shape.createOutline(size, layoutDirection, this), base) + } + } + } + m = m.clip(shape) + if (container.alpha > 0f) { + val shade = if (container.luminance() > 0.5f) look.bottomShadeOnLight else look.bottomShade + m = if (look.topLight > 0f || shade > 0f) { + m.background( + Brush.verticalGradient( + 0f to lerp(container, Color.White, look.topLight), + 0.5f to container, + 1f to lerp(container, Color.Black, shade), + ), + ) + } else { + m.background(container) + } + if (latched && look.insetAlpha > 0f) { + val inset = look.insetAlpha + m = m.drawBehind { + drawRect(Brush.verticalGradient(0f to Color.Black.copy(alpha = inset), 0.3f to Color.Transparent)) + } + } + if (look.rimAlpha > 0f) { + m = m.border(1.dp, Brush.verticalGradient(listOf(Color.White.copy(alpha = look.rimAlpha), Color.Transparent)), shape) + } + if (!latched && (look.bevelTop > 0f || look.bevelBottom > 0f)) { + m = m.border( + 1.dp, + Brush.verticalGradient( + 0f to Color.White.copy(alpha = look.bevelTop), + 0.3f to Color.Transparent, + 0.7f to Color.Transparent, + 1f to Color.Black.copy(alpha = look.bevelBottom), + ), + shape, + ) + } + } + if (border.isSpecified) m = m.border(BorderStroke(1.dp, border), shape) + if (pressed) m = m.drawWithContent { + drawContent() + drawRect(Color.Black.copy(alpha = look.pressedDim)) + } + return m +} + +/** + * A blurred shadow falling [elevation] below the control, faked with a few stacked, growing + * outlines: cheap, works on every API level, and already soft enough that the car's video + * compression has nothing crisp to smear. + */ +private fun DrawScope.drawSoftShadow(shape: Shape, elevation: Float, alpha: Float, lift: Float) { + if (alpha <= 0f) return + val blur = elevation * 2f + val steps = 6 + for (i in steps downTo 1) { + val grow = blur * i / steps - blur * 0.35f + val outline = shape.createOutline( + Size((size.width + grow * 2).coerceAtLeast(0f), (size.height + grow * 2).coerceAtLeast(0f)), + layoutDirection, + this, + ) + translate(-grow, elevation * lift - grow) { + drawOutline(outline, Color.Black.copy(alpha = alpha / steps)) + } + } +} + +/** + * Whether a control should look pressed. Held for at least [MIN_PRESS_MS] after the finger lifts: + * a quick tap would otherwise come and go between two frames of the car's video stream. + */ +@Composable +private fun rememberPressed(source: MutableInteractionSource): Boolean { + var shown by remember { mutableStateOf(false) } + LaunchedEffect(source) { + var since = 0L + var release: Job? = null + source.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> { + release?.cancel() + since = SystemClock.uptimeMillis() + shown = true + } + is PressInteraction.Release, is PressInteraction.Cancel -> { + release?.cancel() + release = launch { + delay((MIN_PRESS_MS - (SystemClock.uptimeMillis() - since)).coerceAtLeast(0)) + shown = false + } + } + } + } + } + return shown +} + +private const val MIN_PRESS_MS = 140L + +@Composable +private fun Modifier.gsPressable( + source: MutableInteractionSource, + pressed: Boolean, + enabled: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + role: Role = Role.Button, +): Modifier { + val look = gsLook() + return this + .scale(if (pressed) look.pressedScale else 1f) + .combinedClickable( + interactionSource = source, + indication = null, + enabled = enabled, + role = role, + onLongClick = onLongClick, + onClick = onClick, + ) +} + +private fun GsColors.disabled(enabled: Boolean, onSurface: Color): GsColors = + if (enabled) this + else GsColors( + container = if (container.alpha > 0f) onSurface.copy(alpha = 0.12f) else container, + content = onSurface.copy(alpha = 0.38f), + border = if (border.isSpecified) onSurface.copy(alpha = 0.12f) else border, + ) + +/** A themed text (or text-and-icon) button. */ +@Composable +fun GsButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + tone: GsTone = GsTone.Primary, + colors: GsColors = gsColors(tone), + enabled: Boolean = true, + contentPadding: PaddingValues = PaddingValues(horizontal = 24.dp, vertical = 10.dp), + content: @Composable RowScope.() -> Unit, +) { + val c = colors.disabled(enabled, MaterialTheme.colorScheme.onSurface) + val shape = gsShape() + val source = remember { MutableInteractionSource() } + val pressed = rememberPressed(source) + Row( + modifier + .defaultMinSize(minWidth = 64.dp, minHeight = 48.dp) + .gsPressable(source, pressed, enabled, onClick) + .gsSurface(c.container, shape, c.border, pressed) + .padding(contentPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalContentColor provides c.content) { + ProvideTextStyle(MaterialTheme.typography.labelLarge) { content() } + } + } +} + +/** A themed square icon button. Pass a [modifier] size to override the default [size]. */ +@Composable +fun GsIconButton( + icon: ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tone: GsTone = GsTone.Neutral, + colors: GsColors = gsColors(tone), + enabled: Boolean = true, + size: Dp = 48.dp, + iconSize: Dp = 24.dp, + shape: Shape = gsShape(), +) { + GsIconBox(onClick, modifier.size(size), colors, enabled, shape) { + Icon(icon, contentDescription, Modifier.size(iconSize)) + } +} + +/** [GsIconButton] with arbitrary content, for glyphs drawn in code or app icons. */ +@Composable +fun GsIconBox( + onClick: () -> Unit, + modifier: Modifier = Modifier, + colors: GsColors = gsColors(GsTone.Neutral), + enabled: Boolean = true, + shape: Shape = gsShape(), + /** Held down, as a keycap theme draws it - for a toggle or the current screen's nav button. */ + latched: Boolean = false, + onLongClick: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit, +) { + val c = colors.disabled(enabled, MaterialTheme.colorScheme.onSurface) + val source = remember { MutableInteractionSource() } + val pressed = rememberPressed(source) + Box( + modifier + .gsPressable(source, pressed, enabled, onClick, onLongClick) + .gsSurface(c.container, shape, c.border, pressed, latched), + contentAlignment = Alignment.Center, + ) { + CompositionLocalProvider(LocalContentColor provides c.content) { content() } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsSeekBar.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsSeekBar.kt new file mode 100644 index 0000000..0f5737b --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsSeekBar.kt @@ -0,0 +1,83 @@ +package app.seb3thehacker.gearslip.car.theme + +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp + +/** + * A seek bar in the current theme's finish: the track is set into the surface (drawn latched, so + * themes with a pressed-in look sink it), the played part and the knob are raised like a button. + * Tapping or dragging seeks; the position is committed on release rather than on every pixel of a + * drag, because each commit is a request to the media app. + */ +@Composable +fun GsSeekBar( + fraction: Float, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val scheme = MaterialTheme.colorScheme + var dragging by remember { mutableStateOf(null) } + val shown = (dragging ?: fraction).coerceIn(0f, 1f) + val track = 8.dp + val knob = 20.dp + + BoxWithConstraints( + modifier + .fillMaxWidth() + .height(32.dp) + .then( + if (!enabled) Modifier else Modifier + .pointerInput(Unit) { detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) } } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { dragging = (it.x / size.width).coerceIn(0f, 1f) }, + onDragEnd = { dragging?.let(onSeek); dragging = null }, + onDragCancel = { dragging = null }, + ) { change, _ -> dragging = (change.position.x / size.width).coerceIn(0f, 1f) } + }, + ), + contentAlignment = Alignment.CenterStart, + ) { + // The knob's centre travels from half a knob in to half a knob from the end, so it never + // hangs off either edge; the fill runs to its centre. + val travel = maxWidth - knob + val centre = knob / 2 + travel * shown + Box( + Modifier + .fillMaxWidth() + .height(track) + .gsSurface(scheme.onSurface.copy(alpha = 0.14f), CircleShape, latched = true), + ) + Box( + Modifier + .width(centre) + .height(track) + .gsSurface(scheme.primary, CircleShape), + ) + Box( + Modifier + .offset(x = centre - knob / 2) + .size(knob) + .gsSurface(scheme.primary, CircleShape, pressed = dragging != null), + ) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt new file mode 100644 index 0000000..ba351ce --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsTheme.kt @@ -0,0 +1,114 @@ +package app.seb3thehacker.gearslip.car.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * How the car UI's controls look, separate from its colours: [MaterialTheme]'s colour scheme still + * decides what colour a button is, and this decides how that colour is drawn. A theme is plain + * data, so a user-supplied theme later is just another instance - nothing about the built-in ones + * is special beyond being listed in [GsThemes.builtIn]. + */ +@Immutable +data class GsTheme( + /** Stable key persisted in settings; never reuse one for a different look. */ + val id: String, + val name: String, + val control: ControlLook, +) + +/** The finish on anything tappable that has a container: buttons, toggles, keypad keys. */ +@Immutable +data class ControlLook( + val corner: Dp = 12.dp, + /** How far the top edge is lifted towards white, 0..1, fading evenly to the base colour at the centre. */ + val topLight: Float = 0f, + /** How far the bottom edge is pushed towards black, 0..1. */ + val bottomShade: Float = 0f, + /** + * [bottomShade] for containers that are already light, where lifting the top towards white + * barely shows: those get their depth from a darker bottom edge instead. + */ + val bottomShadeOnLight: Float = 0f, + /** Alpha of the thin highlight along the top rim, fading out down the sides; 0 for none. */ + val rimAlpha: Float = 0f, + /** A solid darker base under the button, like a keycap; 0 for none. */ + val lip: Dp = 0.dp, + /** How far a soft drop shadow falls below the button; 0 for none. Its blur is twice this. */ + val shadow: Dp = 0.dp, + val shadowAlpha: Float = 0f, + /** + * A 1px bevel on the edge only - light along the top, dark along the bottom - so depth reads + * at any size without shading the face itself. 0 for none. + */ + val bevelTop: Float = 0f, + val bevelBottom: Float = 0f, + /** How far the face drops while pressed; 0 for none. */ + val sink: Dp = 0.dp, + /** + * Alpha of the shadow cast inside the top edge of a latched control (a selected option, the + * current screen's nav button), so it reads as pushed in. 0 for none. + */ + val insetAlpha: Float = 0f, + /** + * Alpha of the dark overlay while pressed. Heavier than a ripple on purpose: the car screen + * shows a video stream a frame or two behind the finger, so subtle feedback gets lost. + */ + val pressedDim: Float = 0.22f, + /** Scale while pressed, 1 for none. */ + val pressedScale: Float = 0.95f, + /** Replaces this look when the UI is light, for effects that only work on one background. */ + val light: ControlLook? = null, +) + +object GsThemes { + val Keycap = GsTheme( + id = "keycap", + name = "Keycap", + control = ControlLook(lip = 4.dp, pressedScale = 1f), + ) + + val Tactile = GsTheme( + id = "tactile", + name = "Tactile", + control = ControlLook( + topLight = 0.05f, + shadow = 3.dp, + shadowAlpha = 0.32f, + bevelTop = 0.16f, + bevelBottom = 0.22f, + sink = 2.dp, + insetAlpha = 0.30f, + pressedDim = 0.12f, + pressedScale = 1f, + // A wide soft shadow bands once the car's video stream compresses it on a light + // background, so light mode gets a tight one and leans on the bevel instead. + light = ControlLook( + topLight = 0.05f, + shadow = 1.5.dp, + shadowAlpha = 0.38f, + bevelTop = 0.24f, + bevelBottom = 0.30f, + sink = 2.dp, + insetAlpha = 0.30f, + pressedDim = 0.12f, + pressedScale = 1f, + ), + ), + ) + + val Flat = GsTheme( + id = "flat", + name = "Flat", + control = ControlLook(), + ) + + val default = Tactile + val builtIn = listOf(Tactile, Keycap, Flat) + + fun byId(id: String?): GsTheme = builtIn.firstOrNull { it.id == id } ?: default +} + +val LocalGsTheme = staticCompositionLocalOf { GsThemes.default } 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 9217903..55000ab 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -108,7 +108,9 @@ class CarAppConnection(private val context: Context) { // --- connection --------------------------------------------------------------------- fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int, destination: String? = null) { - disconnect() + // Keeps the location hold through a reconnect: stopping and restarting the service can be + // refused once the phone is locked, leaving the new app without location. + disconnect(keepLocation = true) frameWidth = width frameHeight = height frameDensity = densityDpi @@ -133,8 +135,9 @@ class CarAppConnection(private val context: Context) { } binding = connection - LocationKeepAlive.start(context) - val bind = { + val bind = bind@{ + // Replaced by another connect (or a disconnect) while waiting on the location service. + if (binding !== connection) return@bind var bindError: Throwable? = null val bound = runCatching { context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES) @@ -151,9 +154,20 @@ class CarAppConnection(private val context: Context) { } } - bind() + // BIND_INCLUDE_CAPABILITIES only lends the location capability Gearslip holds at bind + // time, so the keep-alive has to be running first - a nav app that starts its own + // location service before then is refused, and MapQuest crashes on that. + if (holdsLocation) { + bind() + } else { + holdsLocation = true + LocationKeepAlive.acquire(context) { bind() } + } } + /** Whether this connection has a hold on [LocationKeepAlive]. */ + private var holdsLocation = false + /** * A service that names a permission can only be bound by an app holding it. Some navigation apps * name one that only Google's own Android Auto host holds, and no other app can be granted. @@ -187,7 +201,9 @@ class CarAppConnection(private val context: Context) { return "$kind at $step: ${firstLine.substringAfterLast("Exception: ", firstLine)}" } - fun disconnect() { + fun disconnect() = disconnect(keepLocation = false) + + private fun disconnect(keepLocation: Boolean) { lent?.let { sendSurfaceDestroyed(it) } lent = null val carApp = this.carApp @@ -195,7 +211,10 @@ class CarAppConnection(private val context: Context) { runCatching { carApp.onAppPause(noop("onAppPause")) } runCatching { carApp.onAppStop(noop("onAppStop")) } } - LocationKeepAlive.stop(context) + if (holdsLocation && !keepLocation) { + holdsLocation = false + LocationKeepAlive.release(context) + } binding?.let { runCatching { context.unbindService(it) } } binding = null this.carApp = null diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarDescriptor.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarDescriptor.kt new file mode 100644 index 0000000..b411957 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarDescriptor.kt @@ -0,0 +1,42 @@ +package app.seb3thehacker.gearslip.host + +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Bundle +import org.xmlpull.v1.XmlPullParser + +/** + * An app's Android Auto descriptor: the `com.google.android.gms.car.application` meta-data, + * pointing at an xml file whose `` lines say what the app does in a car - + * `media`, `notification` (messaging), `template` and so on. + */ +object CarDescriptor { + + const val META = "com.google.android.gms.car.application" + + /** The meta-data holding the descriptor, from the service first and then the app. */ + fun meta(manager: PackageManager, pkg: String, service: ServiceInfo? = null): Bundle? = + service?.metaData?.takeIf { it.containsKey(META) } + ?: runCatching { manager.getApplicationInfo(pkg, PackageManager.GET_META_DATA).metaData } + .getOrNull()?.takeIf { it.containsKey(META) } + + /** + * The `uses` names in [pkg]'s descriptor. Null when it has no descriptor, or one that can't + * be read; empty when it lists nothing. + */ + fun uses(manager: PackageManager, pkg: String, service: ServiceInfo? = null): Set? { + val resId = meta(manager, pkg, service)?.getInt(META) ?: return null + if (resId == 0) return null + return runCatching { + manager.getResourcesForApplication(pkg).getXml(resId).use { xml -> + buildSet { + while (xml.next() != XmlPullParser.END_DOCUMENT) { + if (xml.eventType == XmlPullParser.START_TAG && xml.name == "uses") { + xml.getAttributeValue(null, "name")?.let(::add) + } + } + } + } + }.getOrNull() + } +} 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 8ab1a90..c67492e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import app.seb3thehacker.gearslip.GearslipLog import java.util.concurrent.TimeUnit +import kotlin.math.roundToInt /** * Turns the Car App Library's model objects into things Compose can draw. @@ -102,9 +103,16 @@ fun CarColor?.color(dark: Boolean, default: Color): Color = when (this?.type) { else -> default } -/** "500 ft", "2.4 mi" - the app picks the unit, the host formats it. */ +/** + * "500 ft", "2.4 mi". The app picks the unit and its rounding; when that's the other system from + * the one chosen in Gearslip's settings, the distance is converted and rounded here instead. + */ fun Distance?.display(): String { val distance = this ?: return "" + val appImperial = displayUnit in setOf(Distance.UNIT_MILES, Distance.UNIT_MILES_P1, Distance.UNIT_FEET, Distance.UNIT_YARDS) + val appMetric = displayUnit in setOf(Distance.UNIT_METERS, Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1) + val imperial = app.seb3thehacker.gearslip.car.CarSettings.imperial() + if ((imperial && appMetric) || (!imperial && appImperial)) return convertDistance(distance.meters(), imperial) val unit = when (displayUnit) { Distance.UNIT_METERS -> "m" Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> "km" @@ -121,6 +129,32 @@ fun Distance?.display(): String { return "%.${decimals}f $unit".format(distance.displayDistance) } +private fun Distance.meters(): Double = displayDistance * when (displayUnit) { + Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0 + Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344 + Distance.UNIT_FEET -> 0.3048 + Distance.UNIT_YARDS -> 0.9144 + else -> 1.0 +} + +/** + * Rounded the way a nav app would: feet in steps of 50 under a tenth of a mile, metres in steps + * of 10 (50 past 300) under a kilometre, one decimal under 10 miles or km, whole numbers above. + */ +private fun convertDistance(meters: Double, imperial: Boolean): String { + if (imperial) { + val miles = meters / 1609.344 + if (miles < 0.1) return "${(meters / 0.3048 / 50).roundToInt() * 50} ft" + return if (miles < 10) "%.1f mi".format(miles) else "%.0f mi".format(miles) + } + if (meters < 1000) { + val step = if (meters < 300) 10 else 50 + return "${(meters / step).roundToInt() * step} m" + } + val km = meters / 1000 + return if (km < 10) "%.1f km".format(km) else "%.0f km".format(km) +} + /** "18 min" from the app's remaining-time estimate. */ fun remainingTime(seconds: Long): String { if (seconds <= 0) return "" diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt index e532e45..2932865 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt @@ -9,7 +9,9 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo +import android.os.Handler import android.os.IBinder +import android.os.Looper import app.seb3thehacker.gearslip.GearslipLog /** @@ -41,13 +43,23 @@ class LocationKeepAlive : Service() { // it; a nav app losing its background location fix is a small loss next to that. runCatching { startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + }.onSuccess { + settle(State.ON) }.onFailure { GearslipLog.w("location keep-alive: could not start the foreground service - ${it.message}") + settle(State.FAILED) stopSelf() } return START_NOT_STICKY } + override fun onDestroy() { + if (state == State.ON) state = State.OFF + super.onDestroy() + } + + enum class State { OFF, STARTING, ON, FAILED } + companion object { private const val CHANNEL = "gearslip_location" private const val NOTIFICATION_ID = 44 @@ -55,14 +67,73 @@ class LocationKeepAlive : Service() { /** Public flag value; the constant itself is hidden from the SDK. */ const val BIND_INCLUDE_CAPABILITIES = 0x1000 - fun start(context: Context) { - val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - if (granted) runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) } + private val main = Handler(Looper.getMainLooper()) + + @Volatile var state = State.OFF + private set + + /** Each connected car app holds one; the service stops only when the last lets go. */ + private var holders = 0 + private val waiting = mutableListOf<() -> Unit>() + + /** + * Takes a hold on the service, starting it if it isn't running, and calls [ready] (on the + * main thread) once it's running, has failed, or [timeoutMs] has passed - whichever comes + * first. A nav app bound before this has settled has no location capability to borrow, + * and some (MapQuest) crash outright when their own location service is refused. + */ + fun acquire(context: Context, timeoutMs: Long = 3_000, ready: () -> Unit) { + main.post { + holders++ + if (state == State.OFF || state == State.FAILED) begin(context.applicationContext) + if (state != State.STARTING) { + ready() + return@post + } + var done = false + val once = { if (!done) { done = true; ready() } } + waiting += once + main.postDelayed({ + if (!done) GearslipLog.w("location keep-alive: still not running after ${timeoutMs}ms, binding anyway") + waiting.remove(once) + once() + }, timeoutMs) + } } - fun stop(context: Context) { - context.stopService(Intent(context, LocationKeepAlive::class.java)) + /** Lets go of one hold; the last one stops the service. */ + fun release(context: Context) { + main.post { + holders = (holders - 1).coerceAtLeast(0) + if (holders > 0) return@post + context.stopService(Intent(context, LocationKeepAlive::class.java)) + state = State.OFF + } + } + + private fun begin(context: Context) { + val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!granted) { + GearslipLog.w("location keep-alive: no location permission, car maps won't get a fix in the background") + settle(State.FAILED) + return + } + state = State.STARTING + runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) } + .onFailure { + GearslipLog.w("location keep-alive: start refused - ${it.message}") + settle(State.FAILED) + } + } + + private fun settle(to: State) { + main.post { + state = to + val run = waiting.toList() + waiting.clear() + run.forEach { it() } + } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/ArtColor.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/ArtColor.kt new file mode 100644 index 0000000..c719952 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/ArtColor.kt @@ -0,0 +1,47 @@ +package app.seb3thehacker.gearslip.media + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.core.graphics.scale + +/** + * The colour a piece of album art reads as, for tinting what sits around it. Not the plain + * average, which turns most covers brown-grey: the art is shrunk to a few hundred pixels, + * sorted into hue buckets weighted by how saturated and bright each pixel is, and the heaviest + * bucket wins. Art with no real colour in it (black and white, near-grey) gives null. + */ +object ArtColor { + + private const val SAMPLE = 24 + private const val BUCKETS = 24 + + /** Hue in degrees and saturation, 0..1; null when the art has no colour worth picking. */ + class Tint(val hue: Float, val saturation: Float) + + fun of(art: Bitmap): Tint? { + // A hardware bitmap can't be read pixel by pixel; copy it into memory first. + val readable = if (art.config == Bitmap.Config.HARDWARE) art.copy(Bitmap.Config.ARGB_8888, false) else art + val small = runCatching { readable?.scale(SAMPLE, SAMPLE) }.getOrNull() ?: return null + val weight = FloatArray(BUCKETS) + val hueSum = FloatArray(BUCKETS) + val satSum = FloatArray(BUCKETS) + val hsv = FloatArray(3) + for (y in 0 until small.height) for (x in 0 until small.width) { + Color.colorToHSV(small.getPixel(x, y), hsv) + val (h, s, v) = Triple(hsv[0], hsv[1], hsv[2]) + // Near-grey and near-black pixels (letterbox bars among them) say nothing about the art's colour. + if (s < 0.1f || v < 0.08f) continue + val w = s * v + val b = (h / 360f * BUCKETS).toInt().coerceIn(0, BUCKETS - 1) + weight[b] += w + hueSum[b] += h * w + satSum[b] += s * w + } + if (small !== readable) small.recycle() + if (readable !== art) readable?.recycle() + val best = weight.indices.maxBy { weight[it] } + // A sliver of colour in an otherwise grey cover shouldn't tint the whole panel. + if (weight[best] < SAMPLE * SAMPLE * 0.005f) return null + return Tint(hueSum[best] / weight[best], satSum[best] / weight[best]) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt index 1cb52a3..299985a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt @@ -4,6 +4,10 @@ import android.content.ComponentName import android.content.Context import android.graphics.Bitmap import android.media.MediaMetadata +import android.media.Rating +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat import android.media.browse.MediaBrowser import android.media.session.MediaController import android.media.session.MediaSession @@ -32,7 +36,8 @@ class MediaEntry( val playable: Boolean, ) -class CustomAction(val id: String, val name: String, val extras: Bundle?) +/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */ +class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0) /** One entry in the app's own queue - not its library, whatever it has lined up to play next. */ class QueueTrack( @@ -60,7 +65,40 @@ class NowPlaying( val error: String? = null, /** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */ val activeQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong(), + /** The track's heart rating, for apps that take one; null when the app doesn't rate by heart. */ + val heart: Boolean? = null, ) { + /** The app's own button for [words], matched on its id or name - how most apps offer like, shuffle and repeat. */ + fun customFor(vararg words: String) = custom.firstOrNull { a -> + words.any { a.id.contains(it, ignoreCase = true) || a.name.contains(it, ignoreCase = true) } + } + + /** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */ + val likeAction + get() = custom.firstOrNull { a -> + val text = "${a.id} ${a.name}" + listOf("like", "favorit", "favourit", "heart", "love", "thumb").any { text.contains(it, ignoreCase = true) } && + listOf("dislike", "down").none { text.contains(it, ignoreCase = true) } + } + val shuffleAction get() = customFor("shuffle") + + /** The app's buttons that aren't like, shuffle or repeat - "Start radio" and the like. */ + val otherActions: List + get() { + val known = listOf(likeAction, shuffleAction, repeatAction) + return custom.filter { a -> known.none { it === a } } + } + val repeatAction get() = customFor("repeat", "loop") + + /** + * Liked or not; null when the app offers no way to like at all. An app's own like button + * only says which way it goes next by its name ("Remove from Liked Songs"), so that's read. + */ + val liked: Boolean? + get() = heart ?: likeAction?.name?.let { name -> + listOf("remove", "unlike", "unfav", "unheart").any { name.contains(it, ignoreCase = true) } + } + val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE @@ -124,8 +162,47 @@ class CarMedia(private val context: Context) { private val _queue = MutableStateFlow>(emptyList()) val queue: StateFlow> = _queue.asStateFlow() + /** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */ + class Modes( + val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID, + val repeat: Int = PlaybackStateCompat.REPEAT_MODE_INVALID, + ) + + private val _modes = MutableStateFlow(Modes()) + val modes: StateFlow = _modes.asStateFlow() + private var browser: MediaBrowser? = null private var controller: MediaController? = null + + /** + * The same session through the support library's controller - the framework one has no + * shuffle or repeat calls at all. Every app built on MediaSessionCompat or Media3 answers + * these; one on the bare framework API never reports a mode, and the buttons stay off. + */ + private var compat: MediaControllerCompat? = null + private val compatCallback = object : MediaControllerCompat.Callback() { + override fun onSessionReady() = refreshModes() + override fun onShuffleModeChanged(shuffleMode: Int) = refreshModes() + override fun onRepeatModeChanged(repeatMode: Int) = refreshModes() + } + + private fun attach(c: MediaController) { + controller = c + c.registerCallback(callback) + update(c.metadata, c.playbackState) + _queue.value = queueOf(c.queue) + compat = runCatching { + MediaControllerCompat(context, MediaSessionCompat.Token.fromToken(c.sessionToken)).also { + it.registerCallback(compatCallback, main) + } + }.onFailure { GearslipLog.w("media: no support-library view of the session: ${it.message}") }.getOrNull() + refreshModes() + } + + private fun refreshModes() { + val c = compat + _modes.value = if (c == null) Modes() else Modes(shuffle = c.shuffleMode, repeat = c.repeatMode) + } private var subscribed: String? = null private var rootId: String? = null private val path = ArrayDeque>() // id to title @@ -183,11 +260,7 @@ class CarMedia(private val context: Context) { return } GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared") - controller = session.also { - it.registerCallback(callback) - update(it.metadata, it.playbackState) - _queue.value = queueOf(it.queue) - } + attach(session) _browse.value = BrowseState(loading = false, unavailable = true) _phase.value = Phase.READY } @@ -195,11 +268,7 @@ class CarMedia(private val context: Context) { private fun onConnected(app: MediaApp) { val mb = browser ?: return GearslipLog.i("media: connected to ${app.label}, root=${mb.root}") - controller = MediaController(context, mb.sessionToken).also { - it.registerCallback(callback) - update(it.metadata, it.playbackState) - _queue.value = queueOf(it.queue) - } + attach(MediaController(context, mb.sessionToken)) rootId = mb.root path.clear() _phase.value = Phase.READY @@ -241,9 +310,12 @@ class CarMedia(private val context: Context) { speed = state?.playbackSpeed ?: 1f, updatedAt = state?.lastPositionUpdateTime ?: 0, actions = state?.actions ?: 0, - custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) }, + custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon) }, error = state?.errorMessage?.toString(), activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(), + heart = if (controller?.ratingType == Rating.RATING_HEART) { + metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false + } else null, ) } @@ -320,9 +392,40 @@ class CarMedia(private val context: Context) { fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras) fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId) + /** The app's own like button if it has one, since that's the one its library listens to; else a heart rating. */ + fun toggleLike() { + val now = _now.value + now.likeAction?.let { custom(it); return } + now.heart?.let { controller?.transportControls?.setRating(Rating.newHeartRating(!it)) } + } + + fun toggleShuffle() { + _now.value.shuffleAction?.let { custom(it); return } + val next = if (_modes.value.shuffle == PlaybackStateCompat.SHUFFLE_MODE_NONE) { + PlaybackStateCompat.SHUFFLE_MODE_ALL + } else { + PlaybackStateCompat.SHUFFLE_MODE_NONE + } + compat?.transportControls?.setShuffleMode(next) + } + + /** Off, then the whole queue, then this one track, then off again - the order every player uses. */ + fun cycleRepeat() { + _now.value.repeatAction?.let { custom(it); return } + val next = when (_modes.value.repeat) { + PlaybackStateCompat.REPEAT_MODE_NONE -> PlaybackStateCompat.REPEAT_MODE_ALL + PlaybackStateCompat.REPEAT_MODE_ALL, PlaybackStateCompat.REPEAT_MODE_GROUP -> PlaybackStateCompat.REPEAT_MODE_ONE + else -> PlaybackStateCompat.REPEAT_MODE_NONE + } + compat?.transportControls?.setRepeatMode(next) + } + fun disconnect() { connecting = null main.removeCallbacksAndMessages(null) + runCatching { compat?.unregisterCallback(compatCallback) } + compat = null + _modes.value = Modes() runCatching { controller?.unregisterCallback(callback) } runCatching { subscribed?.let { browser?.unsubscribe(it) } } runCatching { browser?.disconnect() } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt index 09c4f3b..1abd7d6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt @@ -1,6 +1,7 @@ package app.seb3thehacker.gearslip.media import app.seb3thehacker.gearslip.host.AndroidAuto +import app.seb3thehacker.gearslip.host.CarDescriptor import app.seb3thehacker.gearslip.host.KnownApps import android.content.ComponentName import android.content.Context @@ -19,15 +20,13 @@ class MediaApp( /** * Finds media apps that opted in to Android Auto. * - * An app is a car media app when it exports a MediaBrowserService *and* declares the - * `com.google.android.gms.car.application` meta-data pointing at a descriptor with - * ``. The second half is what separates Deezer from a phone-only player - * that merely exposes a browser service to Bluetooth and Wear. + * An app is a car media app when it exports a MediaBrowserService *and* its car descriptor (see + * [CarDescriptor]) lists `media`. The descriptor is what separates Deezer from a phone-only + * player that merely exposes a browser service to Bluetooth and Wear - and from messengers like + * Signal and Molly, which declare only `notification` but keep a browser service for voice notes. */ object MediaCatalog { - private const val CAR_META = "com.google.android.gms.car.application" - fun installed(context: Context): List { val manager = context.packageManager return manager.queryIntentServices(Intent(MediaBrowserService.SERVICE_INTERFACE), PackageManager.GET_META_DATA) @@ -35,7 +34,7 @@ object MediaCatalog { .filter { it.serviceInfo.packageName != context.packageName } .filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) } .filterNot { KnownApps.isHidden(it.serviceInfo.packageName) } - .filter { declaresCar(manager, it.serviceInfo) } + .filter { declaresMedia(manager, it.serviceInfo) } .map { val info = it.serviceInfo MediaApp( @@ -49,11 +48,10 @@ object MediaCatalog { .toList() } - private fun declaresCar(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean { - if (service.metaData?.containsKey(CAR_META) == true) return true - return runCatching { - manager.getApplicationInfo(service.packageName, PackageManager.GET_META_DATA) - .metaData?.containsKey(CAR_META) == true - }.getOrDefault(false) + /** A descriptor that can't be read keeps the app, rather than dropping a real player over an odd manifest. */ + private fun declaresMedia(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean { + if (CarDescriptor.meta(manager, service.packageName, service) == null) return false + val uses = CarDescriptor.uses(manager, service.packageName, service) + return uses.isNullOrEmpty() || "media" in uses } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt index fba7a75..66b18a3 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt @@ -15,6 +15,9 @@ import kotlinx.coroutines.flow.update /** How a notification can be answered: the action the app attached, and the field it fills. */ class ReplyTarget(val action: Notification.Action, val input: android.app.RemoteInput) +/** One message of a conversation. [sender] is null for the phone's owner ("You"). */ +data class ChatLine(val sender: String?, val text: String, val at: Long) + /** * One notification as the car shows it. Only what the car UI needs is kept - the phone's * Notification object stays with the listener, apart from the reply action. @@ -24,6 +27,7 @@ data class CarNotification( val key: String, /** Unique per post, so an update to an already-shown notification still counts as new. */ val id: Long, + val packageName: String, val appLabel: String, val title: String, val text: String, @@ -32,6 +36,12 @@ data class CarNotification( /** Null when the app offered no reply, or once the notification has left the phone. */ val reply: ReplyTarget?, val replied: Boolean = false, + /** The thread's recent messages, oldest first, when the app posts it as a conversation. */ + val lines: List = emptyList(), + val isGroup: Boolean = false, + /** The app's own "mark as read" action, fired once the driver has opened the thread. */ + val markRead: Notification.Action? = null, + val read: Boolean = false, ) /** @@ -83,13 +93,18 @@ object CarNotifications { /** The phone dismissed it. It stays in history, but its reply intent is no longer trustworthy. */ fun gone(key: String) { - _history.update { list -> list.map { if (it.key == key) it.copy(reply = null) else it } } + _history.update { list -> list.map { if (it.key == key) it.copy(reply = null, markRead = null) else it } } } fun dismissPopup(id: Long) { _popup.update { if (it?.id == id) null else it } } + /** While an app's own messages screen is up, its new messages land there, not in a popup. */ + fun dismissPopupFrom(packageName: String) { + _popup.update { if (it?.packageName == packageName) null else it } + } + /** Called while the notification screen is showing: nothing is unread there. */ fun markRead() { _unread.value = 0 @@ -102,6 +117,22 @@ object CarNotifications { _unread.value = 0 } + /** + * Fires the app's own "mark as read", which also clears it from the phone - and with it the + * reply action, so this is the driver's call, not something done on opening the thread. + */ + fun markThreadRead(context: Context, notification: CarNotification): Boolean { + val action = notification.markRead ?: return false + return try { + action.actionIntent.send(context, 0, Intent()) + _history.update { list -> list.map { if (it.key == notification.key) it.copy(read = true, markRead = null) else it } } + true + } catch (e: PendingIntent.CanceledException) { + GearslipLog.w("notify: ${notification.appLabel} withdrew its mark-as-read") + false + } + } + /** Fills the app's reply field and fires its action. Returns false if the app has withdrawn it. */ fun reply(context: Context, notification: CarNotification, text: String): Boolean { val target = notification.reply ?: return false diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt index 49bd63a..99b21ed 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt @@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.notify import android.app.Notification import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationCompat import androidx.core.graphics.drawable.toBitmap /** @@ -41,21 +42,38 @@ class GearslipNotificationListener : NotificationListenerService() { ?: extras.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty() if (title.isBlank() && text.isBlank()) return + val style = runCatching { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(n) }.getOrNull() + // In MessagingStyle a message with no sender is the phone owner's own. + val me = style?.user?.name?.toString() + val lines = style?.messages.orEmpty().takeLast(MAX_LINES).mapNotNull { m -> + val body = m.text?.toString()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val sender = m.person?.name?.toString()?.takeIf { it.isNotBlank() && it != me } + ChatLine(sender, body, m.timestamp) + } + CarNotifications.post( CarNotification( key = sbn.key, id = CarNotifications.nextId(), + packageName = sbn.packageName, appLabel = appLabel(sbn.packageName), - title = title, - text = text, + title = style?.conversationTitle?.toString()?.takeIf { it.isNotBlank() } ?: title, + text = lines.lastOrNull()?.text ?: text, postedAt = sbn.postTime, icon = icon(sbn), reply = replyOf(n), + lines = lines, + isGroup = style?.isGroupConversation == true, + markRead = markReadOf(n), ), quiet, ) } + private fun markReadOf(n: Notification): Notification.Action? = + (n.actions.orEmpty().toList() + Notification.WearableExtender(n).actions) + .firstOrNull { it.actionIntent != null && it.semanticAction == Notification.Action.SEMANTIC_ACTION_MARK_AS_READ } + private fun appLabel(packageName: String): String = runCatching { packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString() }.getOrDefault(packageName.substringAfterLast('.')) @@ -79,6 +97,7 @@ class GearslipNotificationListener : NotificationListenerService() { private companion object { const val ICON_PX = 96 + const val MAX_LINES = 8 val QUIET_CATEGORIES = setOf( Notification.CATEGORY_TRANSPORT, Notification.CATEGORY_PROGRESS, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/MessagingCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/MessagingCatalog.kt new file mode 100644 index 0000000..e81c353 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/MessagingCatalog.kt @@ -0,0 +1,39 @@ +package app.seb3thehacker.gearslip.notify + +import android.content.Context +import android.content.Intent +import app.seb3thehacker.gearslip.host.AndroidAuto +import app.seb3thehacker.gearslip.host.CarDescriptor +import app.seb3thehacker.gearslip.host.KnownApps + +/** A messaging app that has told the system it belongs in a car. */ +class MessagingApp(val packageName: String, val label: String) + +/** + * Finds messaging apps that opted in to Android Auto: those whose car descriptor lists + * `notification` (Signal, Molly, WhatsApp, Telegram, Google Messages). Android Auto has no + * screen of their own to open; it shows their conversations from the notifications they post, + * and so does Gearslip. + */ +object MessagingCatalog { + + fun installed(context: Context): List { + val manager = context.packageManager + val launchable = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + return manager.queryIntentActivities(launchable, 0) + .asSequence() + .map { it.activityInfo.packageName to it.loadLabel(manager).toString() } + .distinctBy { it.first } + .filter { (pkg, _) -> pkg != context.packageName } + .filterNot { (pkg, label) -> AndroidAuto.matches(pkg, label) || KnownApps.isHidden(pkg) } + .filter { (pkg, _) -> CarDescriptor.uses(manager, pkg)?.contains("notification") == true } + .map { (pkg, _) -> + val label = runCatching { + manager.getApplicationLabel(manager.getApplicationInfo(pkg, 0)).toString() + }.getOrDefault(pkg) + MessagingApp(pkg, label) + } + .sortedBy { it.label.lowercase() } + .toList() + } +} 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 7f63a6a..1d44605 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -58,7 +58,7 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { .windowInsetsPadding(WindowInsets.statusBars) .consumeWindowInsets(WindowInsets.statusBars), ) { - if (BuildConfig.DEBUG) DevBuildBanner() + if (BuildConfig.DEBUG && screen != Screen.CAR_PREVIEW) DevBuildBanner() Column(Modifier.weight(1f)) { when (screen) { diff --git a/gearslip/src/main/res/drawable/lyrics_24.xml b/gearslip/src/main/res/drawable/lyrics_24.xml new file mode 100644 index 0000000..2dc3651 --- /dev/null +++ b/gearslip/src/main/res/drawable/lyrics_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/gearslip/src/main/res/drawable/mic_24.xml b/gearslip/src/main/res/drawable/mic_24.xml new file mode 100644 index 0000000..d5de5f8 --- /dev/null +++ b/gearslip/src/main/res/drawable/mic_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c43d235..f967a0d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ okhttp = "5.4.0" kotlinxSerializationJson = "1.11.0" ossLicenses = "17.5.1" webkit = "1.16.0" +androidxMedia = "1.6.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -40,6 +41,8 @@ androidx-car-app-testing = { group = "androidx.car.app", name = "app-testing", v # Only for the ICarHardwareHost AIDL stubs, which the host half has to implement but which # live in the projected artifact rather than the core one. androidx-car-app-projected = { group = "androidx.car.app", name = "app-projected", version.ref = "carApp" } +# MediaControllerCompat: reads and sets shuffle and repeat, which the framework session API has no calls for. +androidx-media = { group = "androidx.media", name = "media", version.ref = "androidxMedia" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }