From cb895f0658529176b5badc01b2b170458df383d4 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 20 Sep 2026 03:00:25 -0700 Subject: [PATCH] Release v0.0.02-pre: car dashboard, nav bar rework, cached launcher - Nav bar is icon-only and shorter, with a now-playing pill on non-home screens - Clock opens a calendar and weather dashboard (adds READ_CALENDAR) - Web page URL is now set from the car's Settings screen - Launcher app scan and weather are cached - Home player gains skip buttons and a larger cover --- gearslip/build.gradle.kts | 4 +- gearslip/src/main/AndroidManifest.xml | 3 + .../app/seb3thehacker/gearslip/AppSettings.kt | 9 + .../gearslip/GearslipActivity.kt | 8 + .../seb3thehacker/gearslip/car/CarAppStage.kt | 2 +- .../gearslip/car/CarDashboardScreen.kt | 242 ++++++++++++++++++ .../gearslip/car/CarHomeScreen.kt | 32 ++- .../seb3thehacker/gearslip/car/CarLauncher.kt | 65 +++-- .../gearslip/car/CarNavigator.kt | 5 + .../gearslip/car/CarSettingsScreen.kt | 51 +++- .../app/seb3thehacker/gearslip/car/CarUi.kt | 159 ++++++++---- .../seb3thehacker/gearslip/car/MediaScreen.kt | 2 + .../seb3thehacker/gearslip/ui/GearslipApp.kt | 34 +++ .../seb3thehacker/gearslip/ui/HomeScreen.kt | 44 +--- .../gearslip/ui/SettingsScreen.kt | 27 -- 15 files changed, 544 insertions(+), 143 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts index ca5c88d..dcce321 100644 --- a/gearslip/build.gradle.kts +++ b/gearslip/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "app.seb3thehacker.gearslip" minSdk = 35 targetSdk = 37 - versionCode = 1 - versionName = "0.1" + versionCode = 2 + versionName = "0.0.02-pre" } // Keeps build output out of the module folder: /build/gearslip/... instead of diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index 383b767..ad3e91f 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -17,6 +17,9 @@ + + + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt index 80e6d3c..7ba954a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt @@ -7,6 +7,7 @@ object AppSettings { private const val PREFS = "gearslip_settings" private const val KEY_STARTUP_URL = "startup_url" + private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning" private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -25,4 +26,12 @@ object AppSettings { fun setStartupUrl(context: Context, value: String) { prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply() } + + /** Whether the newer-car compatibility warning has already been shown and dismissed once. */ + fun hasSeenCompatWarning(context: Context): Boolean = + prefs(context).getBoolean(KEY_SEEN_COMPAT_WARNING, false) + + fun setSeenCompatWarning(context: Context) { + prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply() + } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index 7cc8bc2..66785bb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -98,11 +98,19 @@ class GearslipActivity : ComponentActivity(), Projection { androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), ) { } + /** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */ + private val calendarPermission = registerForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), + ) { } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) != android.content.pm.PackageManager.PERMISSION_GRANTED ) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION) + if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) != + android.content.pm.PackageManager.PERMISSION_GRANTED + ) calendarPermission.launch(android.Manifest.permission.READ_CALENDAR) enableEdgeToEdge() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 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 dfc80d6..dd58342 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -164,7 +164,7 @@ private fun ChromeBar( } /** Corner radius of the home screen's map pane and player. */ -val EMBEDDED_RADIUS = 8.dp +val EMBEDDED_RADIUS = 18.dp /** * Rounds the corners of a SurfaceView, which Compose cannot clip: the app draws into its own diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt new file mode 100644 index 0000000..0ef08ef --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarDashboardScreen.kt @@ -0,0 +1,242 @@ +package app.seb3thehacker.gearslip.car + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.database.Cursor +import android.location.LocationManager +import android.provider.CalendarContract +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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 +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.GearslipLog +import java.net.HttpURLConnection +import java.net.URL +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlinx.coroutines.Dispatchers +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 + * 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. + */ +@Composable +fun CarDashboardScreen() { + val context = LocalContext.current.applicationContext + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + WeatherCard(context) + AgendaCard(context) + } +} + +@Composable +private fun WeatherCard(context: Context) { + val weather by produceState(Weather.LOADING) { + value = withContext(Dispatchers.IO) { fetchWeather(context) } + } + + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { + Row(Modifier.fillMaxWidth().padding(20.dp), verticalAlignment = Alignment.CenterVertically) { + val w = weather + when { + w === Weather.LOADING -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge) + w == null -> Text( + "Weather unavailable - no location fix yet.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> Text( + "${w.tempC.toInt()}°C · ${w.description}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +@Composable +private fun AgendaCard(context: Context) { + val events by produceState?>(null) { + value = withContext(Dispatchers.IO) { fetchAgenda(context) } + } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Coming up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + val list = events + when { + list == null -> Text("Loading calendar…", style = MaterialTheme.typography.bodyMedium) + list.isEmpty() -> Text( + "Nothing in the next week.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> list.forEach { EventRow(it) } + } + } +} + +@Composable +private fun EventRow(event: AgendaEvent) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + eventWhen(event), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + event.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */ +private fun eventWhen(event: AgendaEvent): String { + val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() + val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis } + val today = Calendar.getInstance() + val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) && + start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) + val time = if (event.allDay) "All day" else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis)) + if (sameDay || (event.allDay && start.before(today))) return time + return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time +} + +private data class Weather(val tempC: Double, val description: String) { + companion object { val LOADING = Weather(Double.NaN, "") } +} + +private data class AgendaEvent(val title: String, val startMillis: Long, val allDay: Boolean) + +private const val WEATHER_TTL_MS = 60 * 60 * 1000L +private var cachedWeather: Weather? = null +private var cachedWeatherAt = 0L + +/** Served from memory for up to an hour: weather barely changes, and each call is a network request. */ +private fun fetchWeather(context: Context): Weather? { + val now = System.currentTimeMillis() + cachedWeather?.let { if (now - cachedWeatherAt < WEATHER_TTL_MS) return it } + return loadWeather(context)?.also { cachedWeather = it; cachedWeatherAt = now } +} + +/** Null means no fix or no permission; the card shows a plain empty state either way. */ +private fun loadWeather(context: Context): Weather? { + if (context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + return null + } + val manager = context.getSystemService(LocationManager::class.java) ?: return null + val location = manager.allProviders + .mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() } + .maxByOrNull { it.time } + ?: return null + + return runCatching { + // Open-Meteo: no API key, no account - the simplest thing that gives a real reading. + val url = URL( + "https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}" + + "&longitude=${location.longitude}¤t_weather=true", + ) + val body = (url.openConnection() as HttpURLConnection).run { + connectTimeout = 5_000 + readTimeout = 5_000 + inputStream.bufferedReader().use { it.readText() } + } + val current = JSONObject(body).getJSONObject("current_weather") + Weather(current.getDouble("temperature"), weatherDescription(current.getInt("weathercode"))) + }.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }.getOrNull() +} + +/** Open-Meteo's WMO weather codes, collapsed to what's worth a glance from the driver's seat. */ +private fun weatherDescription(code: Int): String = when (code) { + 0 -> "Clear" + 1, 2 -> "Partly cloudy" + 3 -> "Overcast" + 45, 48 -> "Fog" + in 51..57 -> "Drizzle" + in 61..67 -> "Rain" + in 71..77 -> "Snow" + in 80..82 -> "Rain showers" + in 85..86 -> "Snow showers" + in 95..99 -> "Thunderstorm" + else -> "-" +} + +/** + * Empty list means no permission or nothing scheduled. Looks from the start of today so an + * event already under way still shows, a week ahead so tomorrow's does too, and skips ones + * that have finished. + */ +private fun fetchAgenda(context: Context): List { + if (context.checkSelfPermission(Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) { + return emptyList() + } + + val now = System.currentTimeMillis() + val startOfToday = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) + }.timeInMillis + val end = startOfToday + 7 * 24 * 60 * 60 * 1000L + val uri = CalendarContract.Instances.CONTENT_URI.buildUpon() + .appendPath(startOfToday.toString()) + .appendPath(end.toString()) + .build() + + val events = mutableListOf() + val projection = arrayOf( + CalendarContract.Instances.TITLE, CalendarContract.Instances.BEGIN, + CalendarContract.Instances.END, CalendarContract.Instances.ALL_DAY, + ) + runCatching { + context.contentResolver.query(uri, projection, null, null, "${CalendarContract.Instances.BEGIN} ASC") + ?.use { cursor: Cursor -> + while (cursor.moveToNext() && events.size < 10) { + val allDay = cursor.getInt(3) == 1 + // All-day events are stored in UTC midnight, so "ended" means the day is over, not the instant. + if (!allDay && cursor.getLong(2) < now) continue + events.add(AgendaEvent(cursor.getString(0) ?: "(untitled)", cursor.getLong(1), allDay)) + } + } + }.onFailure { GearslipLog.w("calendar query failed: ${it.message}") } + return events +} 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 fa710e7..84329a9 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow 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 @@ -65,7 +66,7 @@ fun CarHome() { Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Box( Modifier - .weight(if (showPlayer) 1.8f else 1f) + .weight(if (showPlayer) 2.1f else 1f) .fillMaxHeight() .clip(RoundedCornerShape(EMBEDDED_RADIUS)), ) { @@ -117,7 +118,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame } } -/** Play/pause and a seek bar, and nothing else: the full controls are in the media screen. */ +/** Previous, play/pause, next and a seek bar, and nothing else: the full controls are in the media screen. */ @Composable private fun MiniPlayer(modifier: Modifier) { val media = CarServices.media @@ -141,11 +142,11 @@ private fun MiniPlayer(modifier: Modifier) { color = MaterialTheme.colorScheme.surfaceVariant, ) { Column( - Modifier.fillMaxSize().padding(10.dp), + Modifier.fillMaxSize().padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Box(Modifier.size(120.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) { + Box(Modifier.size(140.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) { art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } } Spacer(Modifier.height(10.dp)) @@ -171,12 +172,23 @@ private fun MiniPlayer(modifier: Modifier) { Spacer(Modifier.height(16.dp)) } - FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(64.dp)) { - Icon( - if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, - if (now.playing) "Pause" else "Play", - Modifier.size(38.dp), - ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { media.previous() }, modifier = Modifier.size(48.dp)) { + Icon(MediaIcons.Previous, "Previous", Modifier.size(30.dp)) + } + FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(60.dp)) { + Icon( + if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, + if (now.playing) "Pause" else "Play", + Modifier.size(36.dp), + ) + } + IconButton(onClick = { media.next() }, modifier = Modifier.size(48.dp)) { + Icon(MediaIcons.Next, "Next", Modifier.size(30.dp)) + } } } } 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 eb9a250..f68ac99 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -35,7 +35,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap +import android.content.Context 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -48,6 +51,38 @@ private class Tile( val onClick: () -> Unit, ) +/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */ +private class Entry( + val label: String, + val icon: ImageBitmap?, + val template: TemplateApp? = null, + val media: MediaApp? = null, +) + +/** + * The installed car and media apps with their icons. Scanning the package manager and decoding + * icons is slow, so it happens once per car session; [invalidate] is called when one starts, which + * is the only time new installs are picked up. + */ +private object LauncherCache { + @Volatile var entries: List? = null + + fun invalidate() { entries = null } + + fun load(context: Context): List { + fun iconOf(pkg: String) = runCatching { + context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap() + }.getOrNull() + + val nav = CarAppCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), template = it) } + val media = MediaCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), media = it) } + return (nav + media).sortedBy { it.label.lowercase() }.also { entries = it } + } +} + +/** Forgets the scanned apps so the next launcher open rescans; call at the start of a car session. */ +fun invalidateLauncherApps() = LauncherCache.invalidate() + /** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */ @Composable fun CarLauncher() { @@ -55,32 +90,20 @@ fun CarLauncher() { val navigator = LocalCarNavigator.current val frame by CarEnvironment.frame.collectAsState() - val installed by produceState(initialValue = emptyList(), context, frame) { - value = withContext(Dispatchers.IO) { - fun iconOf(pkg: String) = runCatching { - context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap() - }.getOrNull() - - val nav = CarAppCatalog.installed(context).map { app -> - Tile(app.label, iconOf(app.component.packageName)) { - CarServices.connectNav(app, frame) - navigator.home() - } - } - val media = MediaCatalog.installed(context).map { app -> - Tile(app.label, iconOf(app.component.packageName)) { - CarServices.openMedia(app) - navigator.media() - } - } - (nav + media).sortedBy { it.label.lowercase() } - } + // Scanned once per car session (see LauncherCache), not every time the launcher opens. + val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) { + value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) } } val tiles = listOf( Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") }, Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") }, - ) + installed + listOf( + ) + installed.map { entry -> + Tile(entry.label, entry.icon) { + entry.template?.let { CarServices.connectNav(it, frame); navigator.home() } + entry.media?.let { CarServices.openMedia(it); navigator.media() } + } + } + listOf( Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() }, ) 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 a936b8a..ad4bcfe 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt @@ -10,6 +10,9 @@ sealed interface CarScreen { data object Settings : CarScreen data object Apps : CarScreen + /** Opened from the clock: the day's calendar and the weather, nothing that needs a keyboard. */ + data object Dashboard : CarScreen + /** The current media app, full size: browse, lyrics and controls. */ data object Media : CarScreen @@ -40,6 +43,8 @@ class CarNavigator(startId: String) { fun settings() { current = CarScreen.Settings } + fun dashboard() { current = CarScreen.Dashboard } + fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) } fun open(id: String) { current = CarScreen.App(id) } 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 75c3abd..46ae16f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -26,14 +26,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.ui.CertSummary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** - * Settings that make sense while sitting in the car. Anything that needs a keyboard (the - * startup URL, certificates) lives on the phone, not here. + * Settings that make sense while sitting in the car. The web page URL is edited here, with the + * car's own on-screen keyboard ([CarKeyboard]); certificate management still needs a real + * keyboard and file picker, so it stays on the phone's Settings screen. */ @Composable fun CarSettingsScreen() { @@ -102,6 +105,8 @@ fun CarSettingsScreen() { color = MaterialTheme.colorScheme.onSurfaceVariant, ) + WebPageSetting() + ScreenFit(vehicle) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -142,6 +147,48 @@ private fun ChoiceRow(title: String, options: List>, selecte } } +/** The URL (or raw HTML) the Web app shows. Edited with [CarKeyboard] - no phone in hand needed. */ +@Composable +private fun WebPageSetting() { + val context = LocalContext.current + var editing by remember { mutableStateOf(false) } + var url by remember { mutableStateOf(AppSettings.startupUrl(context)) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Web page", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Surface( + onClick = { editing = !editing }, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + url.ifEmpty { "Built-in test page - tap to set a URL" }, + style = MaterialTheme.typography.bodyLarge, + color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + } + Text( + "Shown by the Web app on the launcher. Applies on the next connection.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (editing) { + CarKeyboard( + url, + onTextChange = { + url = it + AppSettings.setStartupUrl(context, it) + }, + onSubmit = { editing = false }, + ) + } + } +} + @Composable private fun Info(label: String, value: String) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { 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 7c4d50f..51a47d5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.car +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,11 +19,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.background import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -40,7 +42,12 @@ 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.asImageBitmap import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import app.seb3thehacker.gearslip.media.CarMedia +import app.seb3thehacker.gearslip.media.MediaArt import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight @@ -74,7 +81,7 @@ fun CarUi() { val base = LocalDensity.current val insets by CarEnvironment.insets.collectAsState() val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) } - remember(appContext) { CarServices.init(appContext) } + remember(appContext) { invalidateLauncherApps(); CarServices.init(appContext) } DisposableEffect(Unit) { onDispose { CarServices.shutdown() } } // The map follows the light outside, whatever the app's own theme is set to. LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() } @@ -105,6 +112,7 @@ fun CarUi() { CarScreen.Apps -> CarLauncher() CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } } CarScreen.Settings -> CarSettingsScreen() + CarScreen.Dashboard -> CarDashboardScreen() is CarScreen.Notifications -> NotificationsScreen(screen.replyTo) is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke() } @@ -121,67 +129,75 @@ fun CarUi() { } } -/** Back, Home and Apps on the left; clock, phone battery and the darkness signal on the right. */ +/** + * Home and Apps on the left; now-playing pill in the middle; clock (tap for calendar and weather), phone battery and the + * darkness signal on the right. Icon-only: a driver reads a glyph faster than a label, and it + * keeps the bar short enough to leave the app underneath more room. + */ @Composable private fun CarNavBar(navigator: CarNavigator) { val battery by CarEnvironment.battery.collectAsState() val darkOutside by CarEnvironment.darkOutside.collectAsState() val now by rememberNow() + // The home and media screens already show what's playing; everywhere else it lives in the + // middle of this bar so it stays one tap away. + val showNowPlaying = navigator.current != CarScreen.Home && navigator.current != CarScreen.Media + Surface(color = MaterialTheme.colorScheme.surfaceContainer) { - Row( - Modifier - .fillMaxWidth() - .height(80.dp) - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - NavItem(Icons.AutoMirrored.Filled.ArrowBack, "Back", selected = false) { navigator.back() } - Spacer(Modifier.width(8.dp)) - NavItem(Icons.Filled.Home, "Home", navigator.current == CarScreen.Home) { navigator.home() } - Spacer(Modifier.width(8.dp)) - NavItem(Icons.Filled.Menu, "Apps", navigator.current == CarScreen.Apps) { navigator.apps() } + Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) { + Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() } + Spacer(Modifier.width(6.dp)) + NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() } - Spacer(Modifier.weight(1f)) + Spacer(Modifier.weight(1f)) - Column(horizontalAlignment = Alignment.End) { - Text( - DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - ) - val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else "" - val light = if (darkOutside) "Night" else "Day" - Text( - listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable { navigator.dashboard() } + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else "" + val light = if (darkOutside) "Night" else "Day" + Text( + listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(10.dp)) + NavItem( + Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, + ) { navigator.notifications() } + } + + if (showNowPlaying) { + NavNowPlaying(navigator, Modifier.align(Alignment.Center)) } - Spacer(Modifier.width(16.dp)) - NavItem( - Icons.Filled.Notifications, null, navigator.current is CarScreen.Notifications, - ) { navigator.notifications() } } } } @Composable -private fun NavItem(icon: ImageVector, label: String?, selected: Boolean, onClick: () -> Unit) { +private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) { val scheme = MaterialTheme.colorScheme - Column( + Box( Modifier - .clip(RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(18.dp)) .background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer) .clickable(onClick = onClick) - .padding(horizontal = 24.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .padding(12.dp), + contentAlignment = Alignment.Center, ) { - Box { - Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp)) - } - if (label != null) Text(label, style = MaterialTheme.typography.labelLarge) + Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp)) } } @@ -193,3 +209,56 @@ internal fun rememberNow(): State = produceState(System.currentTimeMillis( delay(60_000 - value % 60_000) } } + +/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */ +@Composable +private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) { + val media = CarServices.media + val phase by media.phase.collectAsState() + val now by media.now.collectAsState() + if (phase != CarMedia.Phase.READY || !now.hasTrack) return + 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, + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + 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 + .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) } + } + Text( + now.title.ifEmpty { "Nothing playing" }.let { if (it.length > 15) it.take(15) + "…" else it }, + 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), + ) + } + IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) { + Icon(MediaIcons.Next, "Next", Modifier.size(28.dp)) + } + } + } +} 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 64c8238..a7fa479 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -328,6 +328,8 @@ private fun EntryRow(entry: MediaEntry, media: CarMedia) { internal object MediaIcons { val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z") val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z") + /** The 3x3 app grid; not in material-icons-core. */ + val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z") val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z") private fun icon(name: String, path: String) = ImageVector.Builder( 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 86e394d..e49b67c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -1,11 +1,16 @@ package app.seb3thehacker.gearslip.ui import androidx.activity.compose.BackHandler +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import app.seb3thehacker.gearslip.AppSettings private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW } @@ -26,4 +31,33 @@ fun GearslipApp(onDisconnect: () -> Unit) { Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME }) Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME }) } + + CompatWarning() +} + +/** Shown once, on first run: the certificate is known to fail on head units newer than ~2020. */ +@Composable +private fun CompatWarning() { + val context = LocalContext.current + var visible by rememberSaveable { mutableStateOf(!AppSettings.hasSeenCompatWarning(context)) } + + if (visible) { + AlertDialog( + onDismissRequest = {}, + title = { Text("Before you plug in") }, + text = { + Text( + "Gearslip may not connect on cars or head units newer than about 2020. " + + "Older systems are more likely to work; newer firmware often rejects the " + + "connection outright. See SPIKE_FINDINGS.md in the project for details.", + ) + }, + confirmButton = { + TextButton(onClick = { + AppSettings.setSeenCompatWarning(context) + visible = false + }) { Text("Got it") } + }, + ) + } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 6037027..5acddcc 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -9,6 +9,7 @@ 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 android.content.Context import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -21,18 +22,13 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.SessionStatus -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext @Composable fun HomeScreen( @@ -62,10 +58,13 @@ fun HomeScreen( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, ) + Text( + "v${LocalContext.current.appVersionName()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) Spacer(Modifier.height(16.dp)) StatusCard(status) - Spacer(Modifier.height(24.dp)) - SetupSummary() Spacer(Modifier.height(16.dp)) } @@ -141,32 +140,7 @@ private fun StatusCard(status: SessionStatus.Snapshot) { } } -/** A read-only recap of what a connection will use, so setup problems are visible before the car. */ -@Composable -private fun SetupSummary() { - val context = LocalContext.current - val cert by produceState(null) { - value = withContext(Dispatchers.Default) { CertSummary.read(context) } - } - val startup = AppSettings.startupUrl(context) - - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - SummaryRow("Certificate", cert?.headline ?: "Checking…") - SummaryRow("Car screen shows", startup.ifEmpty { "Built-in test page" }) - } -} - -@Composable -private fun SummaryRow(label: String, value: String) { - Column { - Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text( - value, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } -} +/** The version shown on screen, straight from the manifest - one place to bump per release. */ +private fun Context.appVersionName(): String = + runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?" diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index 8aff7d7..a89dc7c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.CertProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -73,7 +72,6 @@ fun SettingsScreen(onBack: () -> Unit) { verticalArrangement = Arrangement.spacedBy(32.dp), ) { Spacer(Modifier.height(0.dp)) - StartupSection() CertificateSection() Spacer(Modifier.height(16.dp)) } @@ -90,31 +88,6 @@ private fun SectionTitle(text: String) { ) } -@Composable -private fun StartupSection() { - val context = LocalContext.current - var url by remember { mutableStateOf(AppSettings.startupUrl(context)) } - - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - SectionTitle("Startup URL") - OutlinedTextField( - value = url, - onValueChange = { - url = it - AppSettings.setStartupUrl(context, it) - }, - modifier = Modifier.fillMaxWidth(), - label = { Text("URL") }, - placeholder = { Text("https://example.com") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - supportingText = { - Text("Shown on the car screen. Leave empty for the built-in test page. Applies on the next connection.") - }, - ) - } -} - @Composable private fun CertificateSection() { val context = LocalContext.current