Any installed app can be added to the car as a music app or a messenger

Music plays through Gearslip's player from the app's playback session, pressing its Play key when
it isn't running; messages show from its notifications. Added apps can be removed from the Apps card.
This commit is contained in:
Seb3thehacker committed 2026-11-06 15:38:11 -08:00
1 parent 9e1295d0de
commit 5ea62b4bd6
23 files changed
+429 -4

No files matched your search

@@ -0,0 +1,48 @@
package app.seb3thehacker.gearslip.host
import android.content.Context
import app.seb3thehacker.gearslip.AppSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Apps the driver added to the car by hand, for apps that never declared Android Auto support
* (issue #84). A music app is driven through its playback session, the way Spotify's player is;
* a messenger's notifications show on Gearslip's Messages screen, as any car messenger's do.
* Neither needs anything from the app's developer.
*/
object CustomCarApps {
enum class Kind(val id: String) { MEDIA("media"), MESSAGING("messaging") }
data class App(val packageName: String, val kind: Kind)
private const val KEY = "custom_car_apps"
private val flow = MutableStateFlow<List<App>?>(null)
/** Null until [all] has read the settings once. */
val state: StateFlow<List<App>?> = flow
fun all(context: Context): List<App> = flow.value ?: read(context).also { flow.value = it }
fun of(context: Context, kind: Kind): List<App> = all(context).filter { it.kind == kind }
fun isCustom(context: Context, packageName: String): Boolean = all(context).any { it.packageName == packageName }
fun add(context: Context, packageName: String, kind: Kind) = save(context, all(context).filterNot { it.packageName == packageName } + App(packageName, kind))
fun remove(context: Context, packageName: String) = save(context, all(context).filterNot { it.packageName == packageName })
private fun save(context: Context, apps: List<App>) {
flow.value = apps
AppSettings.putString(context, KEY, apps.joinToString(",") { "${it.packageName}:${it.kind.id}" })
}
private fun read(context: Context): List<App> =
AppSettings.getString(context, KEY, "").split(",").mapNotNull { item ->
val pkg = item.substringBefore(':').takeIf { it.isNotBlank() } ?: return@mapNotNull null
val kind = Kind.entries.firstOrNull { it.id == item.substringAfter(':') } ?: return@mapNotNull null
App(pkg, kind)
}
}
@@ -4,6 +4,7 @@ import app.seb3thehacker.gearslip.R
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.view.KeyEvent
import android.graphics.Bitmap
import android.media.MediaMetadata
import android.media.Rating
@@ -281,6 +282,14 @@ class CarMedia(private val context: Context) {
attachToSession(app)
return
}
// Added by hand: no library at all. Wake the app with its Play key if it isn't running,
// then wait for its session as after opening it.
if (app.sessionOnly) {
val running = hasSession(app)
if (!running) pressPlay(app)
if (running) attachToSession(app) else main.postDelayed({ attachToSession(app, launched = true) }, PLAY_WAKE_MS)
return
}
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
override fun onConnected() = this@CarMedia.onConnected(app)
override fun onConnectionFailed() {
@@ -344,7 +353,7 @@ class CarMedia(private val context: Context) {
GearslipLog.w("media: ${app.label} has no active session to control")
// 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.value = if (app.sessionOnly || KnownApps.playerWorks(app.component.packageName)) {
Rejection.NEEDS_APP_RUNNING
} else {
Rejection.REFUSED
@@ -834,6 +843,33 @@ class CarMedia(private val context: Context) {
main.postDelayed({ attachToSession(app, launched = true) }, LAUNCH_WAIT_MS)
}
private fun hasSession(app: MediaApp): Boolean = runCatching {
context.getSystemService(MediaSessionManager::class.java)
.getActiveSessions(ComponentName(context, GearslipNotificationListener::class.java))
.any { it.packageName == app.component.packageName }
}.getOrDefault(false)
/**
* Sends [app] a Play key press, as a headset button would. Most players start their service
* for it, even when closed, and pick up where they left off; one that doesn't is offered to
* open on the phone once the wait runs out.
*/
private fun pressPlay(app: MediaApp) {
val pkg = app.component.packageName
val receiver = context.packageManager.queryBroadcastReceivers(Intent(Intent.ACTION_MEDIA_BUTTON).setPackage(pkg), 0)
.firstOrNull()?.activityInfo ?: run {
GearslipLog.i("media: ${app.label} takes no media keys, so it has to be opened on the phone")
return
}
GearslipLog.i("media: pressing Play for ${app.label}, which isn't running")
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
val press = Intent(Intent.ACTION_MEDIA_BUTTON)
.setClassName(pkg, receiver.name)
.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(action, KeyEvent.KEYCODE_MEDIA_PLAY))
runCatching { context.sendBroadcast(press) }.onFailure { GearslipLog.w("media: Play for ${app.label} failed: ${it.message}") }
}
}
/** 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
@@ -853,6 +889,8 @@ class CarMedia(private val context: Context) {
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
/** How long a player woken by its Play key gets before its session is looked for. */
const val PLAY_WAKE_MS = 1_000L
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
const val SESSION_RETRIES_LAUNCHED = 16
/** How long an app gets to start playing a search before Gearslip says it didn't. */
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.host.CustomCarApps
import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.host.CarDescriptor
import app.seb3thehacker.gearslip.host.KnownApps
@@ -15,6 +17,11 @@ class MediaApp(
val label: String,
/** Whose audio to capture: playback capture is scoped to one uid so nothing else leaks in. */
val uid: Int,
/**
* Added by the driver rather than found (issue #84): there's no library to connect to, so
* the player goes straight to the app's playback session, as it does for Spotify.
*/
val sessionOnly: Boolean = false,
)
/**
@@ -43,10 +50,29 @@ object MediaCatalog {
)
}
.distinctBy { it.component.packageName }
.sortedBy { it.label.lowercase() }
.toList()
.let { found -> found + added(context, found.map { it.component.packageName }.toSet()) }
.sortedBy { it.label.lowercase() }
}
/** The music apps the driver added by hand, unless the app has since become a car app itself. */
private fun added(context: Context, found: Set<String>): List<MediaApp> {
val manager = context.packageManager
return CustomCarApps.of(context, CustomCarApps.Kind.MEDIA).filter { it.packageName !in found }.mapNotNull { custom ->
val info = runCatching { manager.getApplicationInfo(custom.packageName, 0) }.getOrNull() ?: return@mapNotNull null
MediaApp(
// No service to bind: the class name only keeps the component unique and readable.
component = ComponentName(custom.packageName, SESSION_ONLY_CLASS),
label = info.loadLabel(manager).toString(),
uid = info.uid,
sessionOnly = true,
)
}
}
/** What an added app's component names, since it has no browser service. */
const val SESSION_ONLY_CLASS = "gearslip.session-only"
/** 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
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.notify
import app.seb3thehacker.gearslip.host.CustomCarApps
import android.content.Context
import android.content.Intent
import app.seb3thehacker.gearslip.host.AndroidAuto
@@ -33,7 +35,17 @@ object MessagingCatalog {
}.getOrDefault(pkg)
MessagingApp(pkg, label)
}
.sortedBy { it.label.lowercase() }
.toList()
.let { found -> found + added(context, found.map { it.packageName }.toSet()) }
.sortedBy { it.label.lowercase() }
}
/** The messengers the driver added by hand (issue #84); their notifications are all it takes. */
private fun added(context: Context, found: Set<String>): List<MessagingApp> {
val manager = context.packageManager
return CustomCarApps.of(context, CustomCarApps.Kind.MESSAGING).filter { it.packageName !in found }.mapNotNull { custom ->
val info = runCatching { manager.getApplicationInfo(custom.packageName, 0) }.getOrNull() ?: return@mapNotNull null
MessagingApp(custom.packageName, info.loadLabel(manager).toString())
}
}
}
@@ -21,6 +21,12 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.heightIn
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.TextButton
@@ -399,16 +405,131 @@ private fun AppRows() {
LaunchedEffect(Unit) { withContext(Dispatchers.IO) { LauncherCache.load(context, force = true) } }
val rows = apps.orEmpty().mapNotNull { entry -> entry.componentId?.let { it to entry } }
if (rows.isEmpty()) SettingsRow(stringResource(if (apps == null) R.string.car_apps_finding else R.string.car_apps_none))
val custom by app.seb3thehacker.gearslip.host.CustomCarApps.state.collectAsState()
val scope = androidx.compose.runtime.rememberCoroutineScope()
var adding by remember { mutableStateOf(false) }
fun rescan() = scope.launch { withContext(Dispatchers.IO) { LauncherCache.load(context, force = true) } }
rows.forEachIndexed { i, (id, entry) ->
val shown = id !in hidden
val pkg = entry.media?.component?.packageName ?: entry.messaging?.packageName
val added = pkg != null && custom.orEmpty().any { it.packageName == pkg }
if (i > 0) SettingsDivider()
SettingsRow(
entry.label,
Modifier.toggleable(shown, role = Role.Checkbox) { CarSettings.setAppHidden(id, !it) },
subtitle = if (added) stringResource(R.string.car_apps_added_by_you) else null,
leading = { entry.icon?.let { Image(it, contentDescription = null, Modifier.size(40.dp)) } },
trailing = { Checkbox(checked = shown, onCheckedChange = null) },
trailing = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (added && pkg != null) {
androidx.compose.material3.IconButton({
app.seb3thehacker.gearslip.host.CustomCarApps.remove(context, pkg)
rescan()
}) { Icon(androidx.compose.material.icons.Icons.Filled.Delete, stringResource(R.string.car_apps_remove, entry.label)) }
}
Checkbox(checked = shown, onCheckedChange = null)
}
},
)
}
if (apps != null) {
SettingsDivider()
SettingsRow(
stringResource(R.string.car_apps_add),
Modifier.clickable { adding = true },
subtitle = stringResource(R.string.car_apps_add_note),
icon = androidx.compose.material.icons.Icons.Filled.Add,
)
}
if (adding) {
AddCarAppDialog(
listed = rows.mapNotNull { (_, e) -> e.media?.component?.packageName ?: e.messaging?.packageName ?: e.template?.component?.packageName }.toSet(),
onDismiss = { adding = false },
) { pkg, kind ->
app.seb3thehacker.gearslip.host.CustomCarApps.add(context, pkg, kind)
adding = false
rescan()
}
}
}
/**
* Picks an installed app the car doesn't list, then whether it's music or messages (issue #84).
* Music plays through Gearslip's player from the app's playback session; messages show from its
* notifications. Neither needs the app to support Android Auto.
*/
@Composable
private fun AddCarAppDialog(
listed: Set<String>,
onDismiss: () -> Unit,
onAdd: (String, app.seb3thehacker.gearslip.host.CustomCarApps.Kind) -> Unit,
) {
val context = androidx.compose.ui.platform.LocalContext.current
var picked by remember { mutableStateOf<Pair<String, String>?>(null) }
val candidates by androidx.compose.runtime.produceState<List<Triple<String, String, androidx.compose.ui.graphics.ImageBitmap?>>?>(null) {
value = withContext(Dispatchers.IO) {
val manager = context.packageManager
manager.queryIntentActivities(android.content.Intent(android.content.Intent.ACTION_MAIN).addCategory(android.content.Intent.CATEGORY_LAUNCHER), 0)
.map { it.activityInfo.packageName to it.loadLabel(manager).toString() }
.distinctBy { it.first }
.filter { (pkg, _) -> pkg != context.packageName && pkg !in listed }
.sortedBy { it.second.lowercase() }
.map { (pkg, label) ->
val icon = runCatching { manager.getApplicationIcon(pkg).toBitmap(96, 96).asImageBitmap() }.getOrNull()
Triple(pkg, label, icon)
}
}
}
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(picked?.let { stringResource(R.string.car_apps_add_as, it.second) } ?: stringResource(R.string.car_apps_add_pick)) },
text = {
val choice = picked
if (choice == null) {
val list = candidates
if (list == null) {
Text(stringResource(R.string.car_apps_finding))
} else {
androidx.compose.foundation.lazy.LazyColumn(Modifier.heightIn(max = 420.dp)) {
items(list.size) { i ->
val (pkg, label, icon) = list[i]
Row(
Modifier.fillMaxWidth().clickable { picked = pkg to label }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
icon?.let { Image(it, contentDescription = null, Modifier.size(36.dp)) }
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
KindRow(stringResource(R.string.car_apps_as_media), stringResource(R.string.car_apps_as_media_note)) {
onAdd(choice.first, app.seb3thehacker.gearslip.host.CustomCarApps.Kind.MEDIA)
}
KindRow(stringResource(R.string.car_apps_as_messaging), stringResource(R.string.car_apps_as_messaging_note)) {
onAdd(choice.first, app.seb3thehacker.gearslip.host.CustomCarApps.Kind.MESSAGING)
}
}
}
},
confirmButton = {},
dismissButton = {
androidx.compose.material3.TextButton({ if (picked != null) picked = null else onDismiss() }) {
Text(stringResource(if (picked != null) R.string.back else R.string.cancel))
}
},
)
}
@Composable
private fun KindRow(title: String, note: String, onClick: () -> Unit) {
Column(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 12.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
Text(note, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()