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
+}