From db90c72584c38f3a6353f679d6590272f3467f52 Mon Sep 17 00:00:00 2001 From: johnneerdael <7794406+johnneerdael@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:22:37 +0100 Subject: [PATCH] car: choose which apps the drawer shows, from a new Apps card on the phone Short car screens fill up fast, and a phone can offer dozens of car apps that a driver never opens there. The drawer had no way to leave one out. The Apps card comes first under Car, with one row per tile, all ticked. Unticking a row takes the tile out of the drawer at once. It stays pinned to the nav bar if it was, and Gearslip's own tiles aren't listed. Co-Authored-By: Claude Opus 5.5 --- .../seb3thehacker/gearslip/car/CarLauncher.kt | 8 ++- .../seb3thehacker/gearslip/car/CarSettings.kt | 13 ++++ .../app/seb3thehacker/gearslip/car/NavApps.kt | 3 +- .../gearslip/ui/HomeCarSettings.kt | 37 +++++++++- .../seb3thehacker/gearslip/ui/SettingsKit.kt | 2 + gearslip/src/main/res/values/strings.xml | 3 + .../gearslip/car/HiddenAppsTest.kt | 71 +++++++++++++++++++ .../gearslip/car/InMemoryContext.kt | 32 +++++++++ 8 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/car/HiddenAppsTest.kt create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/car/InMemoryContext.kt 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 e4a00e0..ea9dd1f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -224,9 +224,10 @@ private fun groupOf(entry: Entry, mediaPackages: Set): AppGroup { * The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a * group with Settings last of all, then with the driver's own moves from [custom] laid over it. * Moved apps only trade places among themselves, so an app installed later still lands in its group. + * [hidden] leaves tiles out of the drawer only: a pinned app stays on the nav bar. */ -internal fun launcherOrder(all: List, showVehicleData: Boolean, custom: List): List { - val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData } +internal fun launcherOrder(all: List, showVehicleData: Boolean, custom: List, hidden: Set): List { + val shown = all.filter { (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) && it.componentId !in hidden } val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet() val grouped = shown.sortedWith( compareBy({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }), @@ -290,7 +291,8 @@ fun CarLauncher() { } val appOrder by CarSettings.appOrder.collectAsState() - val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf) + val hidden by CarSettings.hiddenApps.collectAsState() + val tiles = launcherOrder(all, showVehicleData, appOrder, hidden).map(::tileOf) LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 108.dp), diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index 470d4af..fe7c123 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -71,6 +71,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning" private const val KEY_DARK_STYLE = "car_dark_style" private const val KEY_PINNED = "car_pinned_apps" private const val KEY_APP_ORDER = "car_app_order" + private const val KEY_HIDDEN_APPS = "car_hidden_apps" private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features" private const val KEY_SYSTEM_SPEECH = "car_system_speech" private const val KEY_CAR_SENSORS = "car_sensors_enabled" @@ -121,6 +122,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled" val uiTheme: StateFlow = uiThemeFlow private val pinnedFlow = MutableStateFlow>(emptySet()) private val appOrderFlow = MutableStateFlow>(emptyList()) + private val hiddenAppsFlow = MutableStateFlow>(emptySet()) private val experimentalFeaturesFlow = MutableStateFlow(false) private val systemSpeechFlow = MutableStateFlow(false) private val carSensorsFlow = MutableStateFlow(false) @@ -218,6 +220,9 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled" /** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */ val appOrder: StateFlow> = appOrderFlow + /** The tiles left out of the car's app drawer, by [Entry.componentId]. */ + val hiddenApps: StateFlow> = hiddenAppsFlow + /** Flattened component names of the map and media apps used last, brought back on connect. */ val lastNav: StateFlow = lastNavFlow val lastMedia: StateFlow = lastMediaFlow @@ -353,6 +358,8 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled" .split(",").filter { it.isNotEmpty() }.toSet() appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "") .split(",").filter { it.isNotEmpty() } + hiddenAppsFlow.value = AppSettings.getString(app, KEY_HIDDEN_APPS, "") + .split(",").filter { it.isNotEmpty() }.toSet() experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true" systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true" carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true" @@ -505,6 +512,12 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled" AppSettings.putString(app, KEY_PINNED, next.joinToString(",")) } + fun setAppHidden(id: String, hide: Boolean) { + val next = if (hide) hiddenAppsFlow.value + id else hiddenAppsFlow.value - id + hiddenAppsFlow.value = next + AppSettings.putString(app, KEY_HIDDEN_APPS, next.joinToString(",")) + } + fun setAppOrder(ids: List) { appOrderFlow.value = ids AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(",")) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt index ee8409f..2f3a65e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NavApps.kt @@ -276,7 +276,8 @@ internal fun AppMenu(navigator: CarNavigator, running: Map) @Composable private fun MoveButtons(id: String) { val custom by CarSettings.appOrder.collectAsState() - val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom).mapNotNull { it.componentId } + val hidden by CarSettings.hiddenApps.collectAsState() + val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom, hidden).mapNotNull { it.componentId } val index = order.indexOf(id) if (index < 0) return fun move(by: Int) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt index 91d79b7..8378a07 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt @@ -4,19 +4,23 @@ import androidx.annotation.StringRes import androidx.compose.ui.res.stringResource import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.DarkStyle +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -24,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,18 +36,23 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.seb3thehacker.gearslip.car.AppTheme import app.seb3thehacker.gearslip.car.CarApps import app.seb3thehacker.gearslip.car.CarSettings +import app.seb3thehacker.gearslip.car.LauncherCache import app.seb3thehacker.gearslip.car.NavBarMode import app.seb3thehacker.gearslip.car.NightMode import app.seb3thehacker.gearslip.car.Units import app.seb3thehacker.gearslip.car.theme.GsThemes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** The car's setting groups, each opening its own screen from the phone's home screen. */ enum class CarGroup(@param:StringRes val title: Int) { + APPS(R.string.crumb_apps), SCREEN(R.string.car_group_screen), NAV_BAR(R.string.car_group_nav_bar), SOUND(R.string.car_group_sound), @@ -102,6 +112,7 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo // Read again each time Home shows, so a place saved a moment ago is listed. val places = androidx.compose.runtime.remember { app.seb3thehacker.gearslip.car.SavedPlaces.all(context) } fun summary(group: CarGroup) = when (group) { + CarGroup.APPS -> listOf(str(R.string.car_apps_sub)) CarGroup.SCREEN -> listOf(opens.label(open), sizes.label(scale), themes.label(theme)) CarGroup.NAV_BAR -> listOf(navBars.label(navBar), str(if (playerLeft) R.string.summary_player_left else R.string.summary_player_right)) CarGroup.SOUND -> listOf( @@ -181,7 +192,8 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) { } else -> Unit } - Text( + // The car's own Settings has no list of apps. + if (group != CarGroup.APPS) Text( stringResource(R.string.car_group_same), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -216,6 +228,7 @@ private fun GroupRows(group: CarGroup) { val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState() when (group) { + CarGroup.APPS -> AppRows() CarGroup.SCREEN -> { Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect) SettingsDivider() @@ -303,6 +316,28 @@ private fun GroupRows(group: CarGroup) { } } +/** One row per tile the installed apps put in the car's drawer, ticked while it shows there. */ +@Composable +private fun AppRows() { + val context = androidx.compose.ui.platform.LocalContext.current + val apps by LauncherCache.state.collectAsState() + val hidden by CarSettings.hiddenApps.collectAsState() + // Its own scan, so an app installed since the last drive is here to choose. + 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)) + rows.forEachIndexed { i, (id, entry) -> + val shown = id !in hidden + if (i > 0) SettingsDivider() + SettingsRow( + entry.label, + Modifier.toggleable(shown, role = Role.Checkbox) { CarSettings.setAppHidden(id, !it) }, + leading = { entry.icon?.let { Image(it, contentDescription = null, Modifier.size(40.dp)) } }, + trailing = { Checkbox(checked = shown, onCheckedChange = null) }, + ) + } +} + private fun List>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty() /** A setting with a few named choices: its current one shown, the rest a tap away. */ diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsKit.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsKit.kt index f58b383..db520af 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsKit.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsKit.kt @@ -91,6 +91,7 @@ internal fun SettingsRow( icon: ImageVector? = null, tint: Color = MaterialTheme.colorScheme.primary, titleColor: Color = MaterialTheme.colorScheme.onSurface, + leading: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = null, ) { Row( @@ -99,6 +100,7 @@ internal fun SettingsRow( horizontalArrangement = Arrangement.spacedBy(16.dp), ) { if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + leading?.invoke() Column(Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor) subtitle?.let { diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index 8a384f9..31c793d 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -148,6 +148,9 @@ Self-signed: %1$s + Choose which apps appear in the car + Finding apps… + No car apps found Screen Nav bar and player Sound and voice diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/HiddenAppsTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/HiddenAppsTest.kt new file mode 100644 index 0000000..3b0c996 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/HiddenAppsTest.kt @@ -0,0 +1,71 @@ +package app.seb3thehacker.gearslip.car + +import app.seb3thehacker.gearslip.notify.MessagingApp +import app.seb3thehacker.gearslip.ui.CarGroup +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class HiddenAppsTest { + private val context = InMemoryContext() + + private fun app(name: String) = Entry(name, icon = null, messaging = MessagingApp(name, name)) + private fun id(name: String) = MESSAGING_PREFIX + name + private fun labels(entries: List) = entries.map { it.label } + + private val apps = listOf(app("signal"), app("telegram"), app("whatsapp")) + + @Before + fun startWithNothingSaved() = CarSettings.init(context) + + @Test + fun everyTileShowsUntilUnticked() { + assertEquals(emptySet(), CarSettings.hiddenApps.value) + assertEquals(apps.map { it.label }, labels(launcherOrder(apps, false, emptyList(), CarSettings.hiddenApps.value))) + } + + @Test + fun anUntickedTileLeavesTheDrawer() { + val shown = launcherOrder(apps, false, emptyList(), setOf(id("telegram"))) + assertEquals(listOf("signal", "whatsapp"), labels(shown)) + } + + @Test + fun gearslipsOwnTilesStayWhenInstalledOnesAreHidden() { + val all = BuiltInApps.entries + apps + val shown = launcherOrder(all, false, emptyList(), apps.mapNotNull { it.componentId }.toSet()) + assertTrue(shown.isNotEmpty()) + assertTrue(shown.all { it.builtIn != null }) + } + + @Test + fun aHiddenTileLeavesTheDriversOrderIntactForTheRest() { + val custom = listOf(id("whatsapp"), id("telegram"), id("signal")) + val shown = launcherOrder(apps, false, custom, setOf(id("telegram"))) + assertEquals(listOf("whatsapp", "signal"), labels(shown)) + } + + @Test + fun aTileComesBackWhenTickedAgain() { + CarSettings.setAppHidden(id("signal"), true) + CarSettings.setAppHidden(id("telegram"), true) + CarSettings.setAppHidden(id("signal"), false) + assertEquals(setOf(id("telegram")), CarSettings.hiddenApps.value) + } + + @Test + fun theChoicesSurviveARestart() { + CarSettings.setAppHidden(id("signal"), true) + CarSettings.setAppHidden(id("whatsapp"), true) + CarSettings.init(context) + assertEquals(setOf(id("signal"), id("whatsapp")), CarSettings.hiddenApps.value) + } + + @Test + fun appsIsTheFirstCardOnThePhone() { + assertEquals(CarGroup.APPS, CarGroup.entries.first()) + assertFalse(CarGroup.entries.drop(1).contains(CarGroup.APPS)) + } +} diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/InMemoryContext.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/InMemoryContext.kt new file mode 100644 index 0000000..55ef702 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/InMemoryContext.kt @@ -0,0 +1,32 @@ +package app.seb3thehacker.gearslip.car + +import android.content.ContextWrapper +import android.content.SharedPreferences +import java.lang.reflect.Proxy + +/** Just enough Context for [CarSettings]: strings kept in a map, so a second init reads them back. */ +class InMemoryContext : ContextWrapper(null) { + val stored = mutableMapOf() + + private val editor: SharedPreferences.Editor = proxy { self, name, args -> + if (name == "putString") stored[args[0] as String] = args[1] as String + self.takeIf { name == "putString" } + } + + private val preferences: SharedPreferences = proxy { _, name, args -> + when (name) { + "edit" -> editor + "getString" -> stored[args[0] as String] ?: args[1] + else -> null + } + } + + override fun getApplicationContext() = this + + override fun getSharedPreferences(name: String?, mode: Int) = preferences + + private inline fun proxy(crossinline call: (Any, String, Array) -> Any?): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { self, method, args -> + call(self, method.name, args.orEmpty()) + } as T +}