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 * 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. * 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. * 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> { 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 } val shown = all.filter { (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) && it.componentId !in hidden }
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet() val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
val grouped = shown.sortedWith( val grouped = shown.sortedWith(
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }), 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 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( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp), 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_DARK_STYLE = "car_dark_style"
private const val KEY_PINNED = "car_pinned_apps" private const val KEY_PINNED = "car_pinned_apps"
private const val KEY_APP_ORDER = "car_app_order" 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_EXPERIMENTAL_FEATURES = "car_experimental_features"
private const val KEY_SYSTEM_SPEECH = "car_system_speech" private const val KEY_SYSTEM_SPEECH = "car_system_speech"
private const val KEY_CAR_SENSORS = "car_sensors_enabled" 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 val uiTheme: StateFlow<String> = uiThemeFlow
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet()) private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList()) private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
private val hiddenAppsFlow = MutableStateFlow<Set<String>>(emptySet())
private val experimentalFeaturesFlow = MutableStateFlow(false) private val experimentalFeaturesFlow = MutableStateFlow(false)
private val systemSpeechFlow = MutableStateFlow(false) private val systemSpeechFlow = MutableStateFlow(false)
private val carSensorsFlow = 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. */ /** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */
val appOrder: StateFlow<List<String>> = appOrderFlow 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. */ /** Flattened component names of the map and media apps used last, brought back on connect. */
val lastNav: StateFlow<String?> = lastNavFlow val lastNav: StateFlow<String?> = lastNavFlow
val lastMedia: StateFlow<String?> = lastMediaFlow val lastMedia: StateFlow<String?> = lastMediaFlow
@@ -359,6 +364,8 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
.split(",").filter { it.isNotEmpty() }.toSet() .split(",").filter { it.isNotEmpty() }.toSet()
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "") appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
.split(",").filter { it.isNotEmpty() } .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" experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true" systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "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(",")) 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>) { fun setAppOrder(ids: List<String>) {
appOrderFlow.value = ids appOrderFlow.value = ids
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(",")) AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
@@ -294,7 +294,8 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
@Composable @Composable
private fun MoveButtons(id: String) { private fun MoveButtons(id: String) {
val custom by CarSettings.appOrder.collectAsState() 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) val index = order.indexOf(id)
if (index < 0) return if (index < 0) return
fun move(by: Int) { fun move(by: Int) {
@@ -4,19 +4,23 @@ import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.theme.DarkStyle import app.seb3thehacker.gearslip.car.theme.DarkStyle
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -24,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -32,19 +37,24 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.AppTheme import app.seb3thehacker.gearslip.car.AppTheme
import app.seb3thehacker.gearslip.car.CarApps import app.seb3thehacker.gearslip.car.CarApps
import app.seb3thehacker.gearslip.car.LauncherMode import app.seb3thehacker.gearslip.car.LauncherMode
import app.seb3thehacker.gearslip.car.CarSettings import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.LauncherCache
import app.seb3thehacker.gearslip.car.NavBarMode import app.seb3thehacker.gearslip.car.NavBarMode
import app.seb3thehacker.gearslip.car.NightMode import app.seb3thehacker.gearslip.car.NightMode
import app.seb3thehacker.gearslip.car.Units import app.seb3thehacker.gearslip.car.Units
import app.seb3thehacker.gearslip.car.theme.GsThemes 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. */ /** The car's setting groups, each opening its own screen from the phone's home screen. */
enum class CarGroup(@param:StringRes val title: Int) { enum class CarGroup(@param:StringRes val title: Int) {
APPS(R.string.crumb_apps),
SCREEN(R.string.car_group_screen), SCREEN(R.string.car_group_screen),
NAV_BAR(R.string.car_group_nav_bar), NAV_BAR(R.string.car_group_nav_bar),
SOUND(R.string.car_group_sound), 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. // 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) } val places = androidx.compose.runtime.remember { app.seb3thehacker.gearslip.car.SavedPlaces.all(context) }
fun summary(group: CarGroup) = when (group) { 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.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.NAV_BAR -> listOf(navBars.label(navBar), str(if (playerLeft) R.string.summary_player_left else R.string.summary_player_right))
CarGroup.SOUND -> listOf( CarGroup.SOUND -> listOf(
@@ -187,7 +198,8 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
} }
else -> 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), stringResource(R.string.car_group_same),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -223,6 +235,7 @@ private fun GroupRows(group: CarGroup) {
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState() val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
when (group) { when (group) {
CarGroup.APPS -> AppRows()
CarGroup.SCREEN -> { CarGroup.SCREEN -> {
Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect) Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect)
SettingsDivider() 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() 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. */ /** 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, icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary, tint: Color = MaterialTheme.colorScheme.primary,
titleColor: Color = MaterialTheme.colorScheme.onSurface, titleColor: Color = MaterialTheme.colorScheme.onSurface,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null, trailing: (@Composable () -> Unit)? = null,
) { ) {
Row( Row(
@@ -99,6 +100,7 @@ internal fun SettingsRow(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
leading?.invoke()
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor) Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
subtitle?.let { subtitle?.let {
+3
View File
@@ -148,6 +148,9 @@
<string name="cert_kind_self">Self-signed: %1$s</string> <string name="cert_kind_self">Self-signed: %1$s</string>
<!-- Car settings on the phone --> <!-- 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_screen">Screen</string>
<string name="car_group_nav_bar">Nav bar and player</string> <string name="car_group_nav_bar">Nav bar and player</string>
<string name="car_group_sound">Sound and voice</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
}