Merge #64: choose which apps the car's drawer shows

This commit is contained in:
Seb3thehacker committed 2026-11-04 14:08:25 -08:00
commit 4172cf1a0a
8 files changed
+164 -5

No files matched your search

@@ -227,9 +227,10 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): 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<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> {
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData }
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>, hidden: Set<String>): List<Entry> {
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<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
@@ -293,7 +294,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),
@@ -72,6 +72,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"
@@ -123,6 +124,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
val uiTheme: StateFlow<String> = uiThemeFlow
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
private val hiddenAppsFlow = MutableStateFlow<Set<String>>(emptySet())
private val experimentalFeaturesFlow = MutableStateFlow(false)
private val systemSpeechFlow = MutableStateFlow(false)
private val carSensorsFlow = MutableStateFlow(false)
@@ -220,6 +222,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<List<String>> = appOrderFlow
/** The tiles left out of the car's app drawer, by [Entry.componentId]. */
val hiddenApps: StateFlow<Set<String>> = hiddenAppsFlow
/** Flattened component names of the map and media apps used last, brought back on connect. */
val lastNav: StateFlow<String?> = lastNavFlow
val lastMedia: StateFlow<String?> = lastMediaFlow
@@ -359,6 +364,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"
@@ -516,6 +523,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<String>) {
appOrderFlow.value = ids
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
@@ -294,7 +294,8 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
@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) {
@@ -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
@@ -32,19 +37,24 @@ import androidx.compose.ui.platform.LocalContext
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.LauncherMode
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),
@@ -108,6 +118,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(
@@ -187,7 +198,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,
@@ -223,6 +235,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()
@@ -319,6 +332,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 <T> List<Pair<T, String>>.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. */
@@ -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 {
+3
View File
@@ -148,6 +148,9 @@
<string name="cert_kind_self">Self-signed: %1$s</string>
<!-- Car settings on the phone -->
<string name="car_apps_sub">Choose which apps appear in the car</string>
<string name="car_apps_finding">Finding apps…</string>
<string name="car_apps_none">No car apps found</string>
<string name="car_group_screen">Screen</string>
<string name="car_group_nav_bar">Nav bar and player</string>
<string name="car_group_sound">Sound and voice</string>
@@ -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<Entry>) = 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<String>(), 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.everyEntry + 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))
}
}
@@ -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<String, String>()
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 <reified T> proxy(crossinline call: (Any, String, Array<out Any?>) -> Any?): T =
Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { self, method, args ->
call(self, method.name, args.orEmpty())
} as T
}