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. */ /** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
private var downTime = 0L 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() stop()
val displayManager = context.getSystemService(DisplayManager::class.java) val displayManager = context.getSystemService(DisplayManager::class.java)
@@ -57,7 +69,7 @@ class ScreenProjector(private val context: Context) {
// driver on every connect. // driver on every connect.
val display = try { val display = try {
displayManager.createVirtualDisplay( displayManager.createVirtualDisplay(
"gearslip-projection", width, height, densityDpi, surface, name, width, height, densityDpi, surface,
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, 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. // own Messages screen does the same job, so a messenger gets only that tile.
val messagingPackages = messagingApps.map { it.packageName }.toSet() val messagingPackages = messagingApps.map { it.packageName }.toSet()
val nav = navApps.filter { it.component.packageName !in messagingPackages }.map { val nav = navApps
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label .filter { it.component.packageName !in messagingPackages }
Entry(label, iconOf(it.component.packageName), template = it) // 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) } 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. // A messenger that is also a car media app keeps only its player tile.
val messaging = messagingApps 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( CarMedia.Rejection.REFUSED -> Notice(
"${app.label} would not let Gearslip browse it.", "${app.label} would not let Gearslip browse it.",
"Some media apps only accept Google's own host.", "Some media apps only accept Google's own host.",
@@ -16,6 +16,8 @@ object KnownApps {
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics "in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches "com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well "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 fun works(packageName: String): Boolean = packageName in working
@@ -31,8 +33,10 @@ object KnownApps {
fun playerWorks(packageName: String): Boolean = packageName in workingPlayer fun playerWorks(packageName: String): Boolean = packageName in workingPlayer
/** /**
* Apps whose car screen refuses Gearslip but whose player works. Only the "· Browse" tile * Apps whose car screen refuses Gearslip but whose player works. When the player covers the
* gets the red X. Car library 1.9 and later accepts only Google's host on a phone. * 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( private val brokenScreen = setOf(
"com.spotify.music", // Spotify 9.1.86 and later: car library 1.9 rejects Gearslip "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.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.MediaMetadata import android.media.MediaMetadata
import android.media.Rating import android.media.Rating
@@ -19,6 +20,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock import android.os.SystemClock
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.KnownApps
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -142,7 +144,7 @@ class CarMedia(private val context: Context) {
enum class Phase { IDLE, CONNECTING, READY, REJECTED } enum class Phase { IDLE, CONNECTING, READY, REJECTED }
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */ /** 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) private val _rejection = MutableStateFlow(Rejection.REFUSED)
val rejection: StateFlow<Rejection> = _rejection.asStateFlow() 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 * is out of reach. Reading another app's session needs Notification access, which the driver
* grants once on the phone. * 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 if (connecting != app) return
val manager = context.getSystemService(MediaSessionManager::class.java) val manager = context.getSystemService(MediaSessionManager::class.java)
val listener = ComponentName(context, GearslipNotificationListener::class.java) val listener = ComponentName(context, GearslipNotificationListener::class.java)
@@ -249,12 +251,19 @@ class CarMedia(private val context: Context) {
return return
} }
if (session == null) { 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. // The app only publishes its session once its service has started; give it a moment.
if (attempt < SESSION_RETRIES) { if (attempt < limit) {
main.postDelayed({ attachToSession(app, attempt + 1) }, SESSION_RETRY_MS) main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS)
} else { } else {
GearslipLog.w("media: ${app.label} has no active session to control") 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 _phase.value = Phase.REJECTED
} }
return return
@@ -437,8 +446,38 @@ class CarMedia(private val context: Context) {
_queue.value = emptyList() _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 { private companion object {
const val SESSION_RETRIES = 6 const val SESSION_RETRIES = 6
const val SESSION_RETRY_MS = 500L 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) }, handler)
projector.start(reader.surface, width, height, density) { CarUi() } projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() }
onDispose { onDispose {
projector.stop() projector.stop()