From cae1b06d055deb9fcb433515f0badfc7c34053b0 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: a display density setting, so text and controls can grow in car apps Size scales Gearslip's own screens but can't reach a hosted app, which draws from the density it's handed. On a short screen those apps stay small. Car display density, under Screen after Size, is From car or 160 to 240 DPI, and the video's resolution stays the same. CarEnvironment.frame is what the display and hosted apps already read, so the choice goes in there, once a session starts: the display and the apps on it can't disagree mid-drive. Reconnecting or reopening the preview applies a change. Phone mirroring keeps the car's own density, and the cluster has its own. Co-Authored-By: Claude Opus 5.5 --- .../gearslip/GearslipActivity.kt | 3 +- .../gearslip/car/CarEnvironment.kt | 17 +++- .../seb3thehacker/gearslip/car/CarSettings.kt | 18 ++++ .../gearslip/ui/CarPreviewScreen.kt | 4 +- .../gearslip/ui/HomeCarSettings.kt | 8 +- gearslip/src/main/res/values/strings.xml | 4 + .../gearslip/car/CarDensityTest.kt | 91 +++++++++++++++++++ .../gearslip/car/InMemoryContext.kt | 32 +++++++ 8 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDensityTest.kt create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/car/InMemoryContext.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index a397a1f..624860b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -296,7 +296,8 @@ class GearslipActivity : ComponentActivity(), Projection { val target = surface ?: return if (screenProjector != null) return val projector = ScreenProjector(this).also { screenProjector = it } - projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() } + // The frame's density, not frameDensity: that stays the car's own, for the phone mirror. + projector.start(target, frameWidth, frameHeight, CarEnvironment.frame.value.densityDpi) { CarUi() } } /** diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt index 103064b..cd145cb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt @@ -37,6 +37,9 @@ object CarEnvironment { private val frameFlow = MutableStateFlow(Frame(800, 480, 160)) val frame: StateFlow = frameFlow + /** The density the car reported; [frame] carries the one the car UI and its apps draw at. */ + private var carDensityDpi = 160 + val phoneDark: StateFlow = phoneDarkFlow val darkOutside: StateFlow = darkOutsideFlow val battery: StateFlow = batteryFlow @@ -95,8 +98,18 @@ object CarEnvironment { } fun setDisplay(width: Int, height: Int, densityDpi: Int) { - displayFlow.value = "${width}x$height @ ${densityDpi}dpi" - frameFlow.value = Frame(width, height, densityDpi) + carDensityDpi = densityDpi + val dpi = CarSettings.effectiveDensity(densityDpi) + displayFlow.value = "${width}x$height @ ${dpi}dpi" + frameFlow.value = Frame(width, height, dpi) + } + + /** + * Takes up the density setting. It's read only as a session starts, so the display and the + * apps on it can't disagree mid-drive; a session with no car behind it, the preview, asks here. + */ + fun applyDensity() { + frameFlow.value = frameFlow.value.copy(densityDpi = CarSettings.effectiveDensity(carDensityDpi)) } fun start(context: Context) { 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..2a6d10a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -53,6 +53,7 @@ enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE } object CarSettings { private const val KEY_SCALE = "car_scale" + private const val KEY_DENSITY = "car_density" private const val KEY_OPEN = "car_open_on_connect" private const val KEY_NIGHT = "car_night_mode" private const val KEY_PIPE_AUDIO = "car_pipe_audio" @@ -97,6 +98,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled" private lateinit var app: Context private val scaleFlow = MutableStateFlow(1f) + private val carDensityFlow = MutableStateFlow(DENSITY_FROM_CAR) private val openFlow = MutableStateFlow(HOME) private val nightFlow = MutableStateFlow(NightMode.AUTO) private val pipeAudioFlow = MutableStateFlow(true) @@ -238,6 +240,16 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled" val moreLyrics: StateFlow = moreLyricsFlow val scale: StateFlow = scaleFlow + + /** [carDensity] when it leaves the choice to the car. */ + const val DENSITY_FROM_CAR = 0 + val DENSITIES = listOf(160, 180, 200, 220, 240) + + /** An absolute DPI for the car UI and its apps; the video's resolution doesn't change with it. */ + val carDensity: StateFlow = carDensityFlow + + /** The density to draw at, for a car or preview that would otherwise use [carDpi]. */ + fun effectiveDensity(carDpi: Int) = carDensityFlow.value.takeIf { it != DENSITY_FROM_CAR } ?: carDpi val openOnConnect: StateFlow = openFlow val nightMode: StateFlow = nightFlow @@ -326,6 +338,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled" fun init(context: Context) { app = context.applicationContext scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f + carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES } ?: DENSITY_FROM_CAR openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME) lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null } lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null } @@ -383,6 +396,11 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled" AppSettings.putString(app, KEY_SCALE, value.toString()) } + fun setCarDensity(dpi: Int) { + carDensityFlow.value = dpi + AppSettings.putString(app, KEY_DENSITY, dpi.toString()) + } + fun setOpenOnConnect(id: String) { openFlow.value = id AppSettings.putString(app, KEY_OPEN, id) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt index 9e228ba..75a35bf 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt @@ -59,7 +59,7 @@ import app.seb3thehacker.gearslip.car.CarUi @Composable fun CarPreviewScreen(onBack: () -> Unit) { val context = LocalContext.current - val frame by CarEnvironment.frame.collectAsStateWithLifecycle() + val frame by remember { CarEnvironment.frame.also { CarEnvironment.applyDensity() } }.collectAsStateWithLifecycle() var viewSize by remember { mutableStateOf(0 to 0) } // Tied to how the phone itself is held, not a button: turn it sideways and the preview goes // fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns. @@ -263,7 +263,7 @@ private fun previewFrame( size: app.seb3thehacker.gearslip.car.PreviewSize, custom: app.seb3thehacker.gearslip.car.ScreenSize?, ): Triple { - fun carLike(w: Int, h: Int) = Triple(w, h, (160 * h / 480).coerceIn(120, 640)) + fun carLike(w: Int, h: Int) = Triple(w, h, app.seb3thehacker.gearslip.car.CarSettings.effectiveDensity((160 * h / 480).coerceIn(120, 640))) return when (size) { app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi) app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM -> 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..bad5572 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt @@ -54,6 +54,8 @@ enum class CarGroup(@param:StringRes val title: Int) { @Composable private fun sizeChoices() = listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)) @Composable +private fun densityChoices() = listOf(CarSettings.DENSITY_FROM_CAR to stringResource(R.string.choice_density_car)) + CarSettings.DENSITIES.map { it to stringResource(R.string.choice_density_dpi, it) } +@Composable private fun themeChoices() = listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)) @Composable private fun darkChoices() = listOf( @@ -194,6 +196,7 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) { @Composable private fun GroupRows(group: CarGroup) { val scale by CarSettings.scale.collectAsState() + val density by CarSettings.carDensity.collectAsState() val open by CarSettings.openOnConnect.collectAsState() val night by CarSettings.nightMode.collectAsState() val units by CarSettings.units.collectAsState() @@ -221,6 +224,8 @@ private fun GroupRows(group: CarGroup) { SettingsDivider() Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale) SettingsDivider() + Choice(stringResource(R.string.car_density), densityChoices(), density, CarSettings::setCarDensity, note = stringResource(R.string.car_density_note)) + SettingsDivider() Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme) SettingsDivider() if (theme != AppTheme.LIGHT) { @@ -307,11 +312,12 @@ private fun List>.label(value: T): String = firstOrNull { it /** A setting with a few named choices: its current one shown, the rest a tap away. */ @Composable -private fun Choice(title: String, choices: List>, selected: T, onSelect: (T) -> Unit) { +private fun Choice(title: String, choices: List>, selected: T, onSelect: (T) -> Unit, note: String? = null) { var menu by remember { mutableStateOf(false) } SettingsRow( title, Modifier.clickable { menu = true }, + subtitle = note, trailing = { // The menu hangs from the value it changes, at the row's end. Box { diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index 8a384f9..56508e4 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -189,6 +189,10 @@ These are the same settings as Settings in the car. A change here shows there too. Open on connect Size + Car display density + Larger text and controls, including in apps. Try 200 DPI with Size on Normal. Reconnect the car or reopen the preview to apply it. If controls clip, choose a lower DPI or From car. + From car + %1$d DPI App theme Dark mode Buttons diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDensityTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDensityTest.kt new file mode 100644 index 0000000..0d874f2 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDensityTest.kt @@ -0,0 +1,91 @@ +package app.seb3thehacker.gearslip.car + +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class CarDensityTest { + private val context = InMemoryContext() + + private fun density() = CarEnvironment.frame.value.densityDpi + + @Before + fun startFromTheCar() { + CarSettings.init(context) + CarEnvironment.setDisplay(800, 480, 160) + } + + @Test + fun fromCarKeepsEachCarsOwnDensity() { + assertEquals(CarSettings.DENSITY_FROM_CAR, CarSettings.carDensity.value) + for (dpi in listOf(120, 180, 213)) { + CarEnvironment.setDisplay(800, 480, dpi) + assertEquals(dpi, density()) + } + } + + @Test + fun aChosenDensityReachesEverythingThatReadsTheFrame() { + for (dpi in CarSettings.DENSITIES) { + CarSettings.setCarDensity(dpi) + CarEnvironment.setDisplay(800, 480, 160) + assertEquals(dpi, density()) + } + } + + @Test + fun aChosenDensityLeavesTheResolutionAlone() { + CarSettings.setCarDensity(240) + CarEnvironment.setDisplay(1280, 720, 160) + assertEquals(CarEnvironment.Frame(1280, 720, 240), CarEnvironment.frame.value) + } + + @Test + fun aNewChoiceWaitsForTheNextSession() { + CarSettings.setCarDensity(200) + CarEnvironment.setDisplay(800, 480, 160) + CarSettings.setCarDensity(240) + assertEquals(200, density()) + CarEnvironment.setDisplay(800, 480, 160) + assertEquals(240, density()) + } + + @Test + fun thePreviewTakesUpANewChoiceWhenItOpens() { + CarSettings.setCarDensity(200) + CarEnvironment.applyDensity() + assertEquals(200, density()) + } + + @Test + fun goingBackToFromCarRestoresTheCarsOwnDensity() { + CarSettings.setCarDensity(240) + CarEnvironment.setDisplay(800, 480, 180) + CarSettings.setCarDensity(CarSettings.DENSITY_FROM_CAR) + CarEnvironment.applyDensity() + assertEquals(180, density()) + } + + @Test + fun aPreviewSizedForThePhoneFollowsTheChoiceToo() { + assertEquals(213, CarSettings.effectiveDensity(213)) + CarSettings.setCarDensity(200) + assertEquals(200, CarSettings.effectiveDensity(213)) + } + + @Test + fun theChoiceSurvivesARestart() { + CarSettings.setCarDensity(220) + CarSettings.init(context) + assertEquals(220, CarSettings.carDensity.value) + } + + @Test + fun aSavedDensityGearslipDoesNotOfferFallsBackToTheCar() { + for (saved in listOf("201", "-1", "broken", "")) { + context.stored["car_density"] = saved + CarSettings.init(context) + assertEquals(CarSettings.DENSITY_FROM_CAR, CarSettings.carDensity.value) + } + } +} 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 +}