new supported apps, redesign UI for apps not fully supported

This commit is contained in:
Seb3thehacker committed 2026-10-01 14:28:44 -07:00
1 parent 4e4a246a51
commit 3994c9bd2e
6 files changed
+81 -14

No files matched your search

@@ -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,
)
@@ -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
@@ -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.",
@@ -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
@@ -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> = _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
}
}
@@ -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()