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