From 3994c9bd2eab9d65344436e8f647c72f5a1ebd1c Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 1 Oct 2026 14:28:44 -0700 Subject: [PATCH] new supported apps, redesign UI for apps not fully supported --- .../seb3thehacker/gearslip/ScreenProjector.kt | 16 +++++- .../seb3thehacker/gearslip/car/CarLauncher.kt | 14 ++++-- .../seb3thehacker/gearslip/car/MediaScreen.kt | 6 +++ .../seb3thehacker/gearslip/host/KnownApps.kt | 8 ++- .../seb3thehacker/gearslip/media/CarMedia.kt | 49 +++++++++++++++++-- .../gearslip/ui/CarPreviewScreen.kt | 2 +- 6 files changed, 81 insertions(+), 14 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt index 3b9202c..7a29fc6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt @@ -45,7 +45,19 @@ class ScreenProjector(private val context: Context) { /** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */ private var downTime = 0L - fun start(surface: Surface, width: Int, height: Int, densityDpi: Int, content: @Composable () -> Unit) { + /** + * [name] names the virtual display so the two callers are told apart from outside the app + * (`dumpsys display`): the real car session keeps "gearslip-projection", the on-phone bench + * preview passes "gearslip-preview". + */ + fun start( + surface: Surface, + width: Int, + height: Int, + densityDpi: Int, + name: String = "gearslip-projection", + content: @Composable () -> Unit, + ) { stop() val displayManager = context.getSystemService(DisplayManager::class.java) @@ -57,7 +69,7 @@ class ScreenProjector(private val context: Context) { // driver on every connect. val display = try { displayManager.createVirtualDisplay( - "gearslip-projection", width, height, densityDpi, surface, + name, width, height, densityDpi, surface, DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, ) 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 66a410f..a5c9ad8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -133,10 +133,16 @@ internal object LauncherCache { // own Messages screen does the same job, so a messenger gets only that tile. val messagingPackages = messagingApps.map { it.packageName }.toSet() - val nav = navApps.filter { it.component.packageName !in messagingPackages }.map { - val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label - Entry(label, iconOf(it.component.packageName), template = it) - } + val nav = navApps + .filter { it.component.packageName !in messagingPackages } + // An app whose template screen is rejected but whose player works through Gearslip + // (Spotify) shows only its player tile - a "· Browse" tile beside it would just report + // that the car screen isn't supported. + .filter { !(KnownApps.screenBroken(it.component.packageName) && KnownApps.playerWorks(it.component.packageName)) } + .map { + val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label + Entry(label, iconOf(it.component.packageName), template = it) + } val media = mediaApps.map { Entry(it.label, iconOf(it.component.packageName), media = it) } // A messenger that is also a car media app keeps only its player tile. val messaging = messagingApps 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 349d03b..fa8f6ee 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -111,6 +111,12 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { } }, ) + CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice( + "${app.label} isn't running.", + "${app.label} plays through Gearslip's own player, but only while it's " + + "open on the phone. Open it once and it keeps playing here in the car.", + action = "Open ${app.label}" to { media.launchAndConnect(app) }, + ) CarMedia.Rejection.REFUSED -> Notice( "${app.label} would not let Gearslip browse it.", "Some media apps only accept Google's own host.", diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt index 19f585f..2693da0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt @@ -16,6 +16,8 @@ object KnownApps { "in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics "com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches "com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well + "com.mapquest.android.ace", // MapQuest: map, search, routing - works well + "nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well ) fun works(packageName: String): Boolean = packageName in working @@ -31,8 +33,10 @@ object KnownApps { fun playerWorks(packageName: String): Boolean = packageName in workingPlayer /** - * Apps whose car screen refuses Gearslip but whose player works. Only the "· Browse" tile - * gets the red X. Car library 1.9 and later accepts only Google's host on a phone. + * Apps whose car screen refuses Gearslip but whose player works. When the player covers the + * app (see [playerWorks]) the launcher drops the dead "· Browse" tile entirely rather than + * show it; otherwise that tile gets a red X. Car library 1.9 and later accepts only Google's + * host on a phone. */ private val brokenScreen = setOf( "com.spotify.music", // Spotify 9.1.86 and later: car library 1.9 rejects Gearslip 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 299985a..a337ad2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.media import android.content.ComponentName import android.content.Context +import android.content.Intent import android.graphics.Bitmap import android.media.MediaMetadata import android.media.Rating @@ -19,6 +20,7 @@ import android.os.Handler import android.os.Looper import android.os.SystemClock import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.host.KnownApps import app.seb3thehacker.gearslip.notify.GearslipNotificationListener import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -142,7 +144,7 @@ class CarMedia(private val context: Context) { enum class Phase { IDLE, CONNECTING, READY, REJECTED } /** Why a connection was [Phase.REJECTED], so the screen can say something useful. */ - enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS } + enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS, NEEDS_APP_RUNNING } private val _rejection = MutableStateFlow(Rejection.REFUSED) val rejection: StateFlow = _rejection.asStateFlow() @@ -236,7 +238,7 @@ class CarMedia(private val context: Context) { * is out of reach. Reading another app's session needs Notification access, which the driver * grants once on the phone. */ - private fun attachToSession(app: MediaApp, attempt: Int = 0) { + private fun attachToSession(app: MediaApp, attempt: Int = 0, launched: Boolean = false) { if (connecting != app) return val manager = context.getSystemService(MediaSessionManager::class.java) val listener = ComponentName(context, GearslipNotificationListener::class.java) @@ -249,12 +251,19 @@ class CarMedia(private val context: Context) { return } if (session == null) { + val limit = if (launched) SESSION_RETRIES_LAUNCHED else SESSION_RETRIES // The app only publishes its session once its service has started; give it a moment. - if (attempt < SESSION_RETRIES) { - main.postDelayed({ attachToSession(app, attempt + 1) }, SESSION_RETRY_MS) + if (attempt < limit) { + main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS) } else { GearslipLog.w("media: ${app.label} has no active session to control") - _rejection.value = Rejection.REFUSED + // A session-only app (Spotify) just isn't running - say so and offer to open it, + // rather than calling it unsupported. + _rejection.value = if (KnownApps.playerWorks(app.component.packageName)) { + Rejection.NEEDS_APP_RUNNING + } else { + Rejection.REFUSED + } _phase.value = Phase.REJECTED } return @@ -437,8 +446,38 @@ class CarMedia(private val context: Context) { _queue.value = emptyList() } + /** + * Opens [app] on the phone, then connects once it publishes its session. The driver asks for + * this from the "Open" button a session-only app (Spotify) shows when it isn't running yet. + */ + fun launchAndConnect(app: MediaApp) { + if (!openApp(app)) return + disconnect() + connecting = app + _phase.value = Phase.CONNECTING + // A cold start takes a few seconds to publish a session, so wait, then watch for longer. + main.postDelayed({ attachToSession(app, launched = true) }, LAUNCH_WAIT_MS) + } + + /** Brings [app] to the foreground on the phone so it publishes its session. */ + private fun openApp(app: MediaApp): Boolean { + val intent = context.packageManager.getLaunchIntentForPackage(app.component.packageName) ?: return false + return runCatching { + context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + GearslipLog.i("media: opened ${app.label} to bring up its session") + true + }.getOrElse { + GearslipLog.w("media: could not open ${app.label}: ${it.message}") + false + } + } + private companion object { const val SESSION_RETRIES = 6 const val SESSION_RETRY_MS = 500L + /** After opening a cold app, how long to wait before the first look for its session. */ + const val LAUNCH_WAIT_MS = 1_500L + /** Retries once the app has been opened: a cold start can take a few seconds to publish. */ + const val SESSION_RETRIES_LAUNCHED = 16 } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt index 3a497bb..f7c3a88 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt @@ -105,7 +105,7 @@ fun CarPreviewScreen(onBack: () -> Unit) { } }, handler) - projector.start(reader.surface, width, height, density) { CarUi() } + projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() } onDispose { projector.stop()