mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-12 08:42:25 +01:00
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:
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()
|
||||
|
||||
Reference in new issue
Block a user