Launcher mode: Gearslip as the home screen of a head unit or a dash tablet

Turned on under Settings > Connection, Gearslip's car screen fills the device's own
display as its home app. An All apps page opens any other app full screen, Back and
a head unit's keys work the car UI, and Phone hides on devices that can't call.
Phones never see Gearslip offered as a home app unless it's switched on.
This commit is contained in:
Seb3thehacker committed 2026-11-03 13:38:48 -08:00
1 parent 07e398cf79
commit 75f43c3e3d
16 files changed
+476 -66

No files matched your search

+20
View File
@@ -160,6 +160,26 @@
</activity>
<!--
Launcher mode: the car UI as this device's home screen, for a head unit that runs Android
or a tablet on the dash. Off until Settings turns it on (LauncherMode), so phones never
list Gearslip as a home app.
-->
<activity
android:name=".LauncherActivity"
android:enabled="false"
android:exported="true"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!--
Answers the car's USB connection on GearslipActivity's behalf. Kept apart from the
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
@@ -47,6 +47,8 @@ object GearslipLog {
val version: StateFlow<Int> = liveVersion
fun init(context: Context) {
// The phone app and the launcher both start here; one file per process.
if (file != null) return
val dir = context.getExternalFilesDir(null) ?: return
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
file = File(dir, "gearslip-$name.log")
@@ -229,7 +231,11 @@ object GearslipLog {
* Records an uncaught exception in the log file before the process dies, then lets the
* system's own handler run. Without it the file ends mid-sentence and the reason is lost.
*/
@Volatile private var crashHandlerInstalled = false
fun installCrashHandler() {
if (crashHandlerInstalled) return
crashHandlerInstalled = true
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
@@ -0,0 +1,147 @@
package app.seb3thehacker.gearslip
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarKeys
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
import app.seb3thehacker.gearslip.car.LauncherMode
import app.seb3thehacker.gearslip.car.Prefetch
import app.seb3thehacker.gearslip.ui.CarFrame
import app.seb3thehacker.gearslip.ui.GearslipTheme
import app.seb3thehacker.gearslip.ui.rememberCarProjector
/**
* The home screen in launcher mode (see [LauncherMode]): the car UI, filling this device's own
* screen. It runs for as long as the activity lives, not only while it's in front, so a map
* keeps guiding and music keeps playing while another app is open over it.
*/
class LauncherActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GearslipLog.init(this)
GearslipLog.installCrashHandler()
CarSettings.init(this)
CarKeys.init(this)
Prefetch.warm(this)
CarEnvironment.setPhoneTheme(resources.configuration)
// A dash screen is something to glance at: it stays on, with the system bars out of the way.
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
// Back moves back in the car UI. A home screen has nowhere else to go back to.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
CarKeys.onKey(KeyEvent.KEYCODE_BACK, down = true, longPress = false)
}
})
setContent {
GearslipTheme {
LauncherScreen(onOpenGearslip = { startActivity(Intent(this, GearslipActivity::class.java)) })
}
}
}
/** Home pressed while Gearslip is already the home screen: back to the car UI's Home. */
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
CarKeys.onKey(KeyEvent.KEYCODE_HOME, down = true, longPress = false)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
CarEnvironment.setPhoneTheme(newConfig)
}
/** A head unit's knob, D-pad and hard keys work the car UI the way a car's do. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode != KeyEvent.KEYCODE_BACK && event.repeatCount == 0 &&
CarKeys.onKey(event.keyCode, event.action == KeyEvent.ACTION_DOWN, event.isLongPress)
) return true
return super.dispatchKeyEvent(event)
}
}
@Composable
private fun LauncherScreen(onOpenGearslip: () -> Unit) {
val status by SessionStatus.state.collectAsStateWithLifecycle()
// Plugged into a car, this device is the phone and the car UI belongs on the car's screen.
val inCar = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
if (inCar) {
InCarNotice(onOpenGearslip)
return
}
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val px = with(LocalDensity.current) { maxWidth.roundToPx() / 2 * 2 to maxHeight.roundToPx() / 2 * 2 }
val (width, height) = px
if (width <= 0 || height <= 0) return@BoxWithConstraints
// Car-like text and buttons: 160dpi for every 480 pixels of height, as the car preview uses.
val density = (160 * height / 480).coerceIn(120, 640)
// Before the projector starts, so the car UI's first screen already knows what to hide.
DisposableEffect(Unit) {
LauncherMode.setActive(true)
onDispose { LauncherMode.setActive(false) }
}
val projector = rememberCarProjector(width, height, density, "gearslip-launcher") { CarUi() }
DisposableEffect(projector) {
CarKeys.focusSink = { code, down -> projector.dispatchKey(code, down) }
onDispose { CarKeys.focusSink = null }
}
CarFrame(projector, width, height, Modifier.fillMaxSize())
}
}
@Composable
private fun InCarNotice(onOpenGearslip: () -> Unit) {
Column(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
stringResource(R.string.launcher_in_car),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(16.dp))
Button(onClick = onOpenGearslip) { Text(stringResource(R.string.launcher_open_gearslip)) }
}
}
@@ -0,0 +1,65 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.mirror.PhoneApp
import app.seb3thehacker.gearslip.mirror.PhoneApps
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Launcher mode's app drawer: every app on the device, A to Z, each opened full screen. Gearslip
* comes first, for its own settings, which the car UI only partly covers.
*/
@Composable
fun AllAppsScreen() {
val context = LocalContext.current
val apps by produceState(initialValue = PhoneApps.cached() ?: emptyList(), context) {
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
}
val gearslip = remember(context) {
val label = context.packageManager.getApplicationLabel(context.applicationInfo).toString()
val icon = runCatching { context.packageManager.getApplicationIcon(context.packageName).toBitmap(ICON_PX, ICON_PX).asImageBitmap() }
PhoneApp(context.packageName, label, icon.getOrNull())
}
Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
ScreenHeader(stringResource(R.string.app_all_apps))
Spacer(Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
PhoneAppTile(gearslip) { LauncherMode.launch(context, context.packageName) }
}
items(apps.sortedBy { it.label.lowercase() }) { app ->
PhoneAppTile(app) { LauncherMode.launch(context, app.packageName) }
}
}
}
}
private const val ICON_PX = 128
@@ -61,7 +61,7 @@ object AssistantCommands {
private suspend fun allEntries(context: Context): List<Entry> {
val vehicleData = CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value
val builtIn = BuiltInApps.entries.filter { it.builtIn !== BuiltInApps.VehicleData || vehicleData }
val builtIn = BuiltInApps.entries(LauncherMode.active.value, LauncherMode.canCall(context)).filter { it.builtIn !== BuiltInApps.VehicleData || vehicleData }
return builtIn + (LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) })
}
@@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.car
import androidx.annotation.StringRes
import app.seb3thehacker.gearslip.R
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
@@ -38,7 +39,16 @@ object CarApps {
CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) {
CarSettingsScreen()
},
CarApp("allapps", "All apps", R.string.app_all_apps, Icons.Filled.List) {
AllAppsScreen()
},
)
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
/** The screens [BuiltInApps.entries] offers here, so a choice list never names a hidden one. */
fun shown(launcher: Boolean, canCall: Boolean): List<CarApp> {
val ids = BuiltInApps.entries(launcher, canCall).mapNotNull { (it.builtIn?.screen as? CarScreen.App)?.id }.toSet()
return all.filter { it.id in ids }
}
}
@@ -185,10 +185,13 @@ private fun tilesFor(
internal fun rememberAllEntries(): List<Entry> {
val context = LocalContext.current
val installed by LauncherCache.state.collectAsState()
val launcher by LauncherMode.active.collectAsState()
LaunchedEffect(context) {
if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(context) }
}
return remember(installed) { BuiltInApps.entries + installed.orEmpty() }
return remember(installed, launcher) {
BuiltInApps.entries(launcher, LauncherMode.canCall(context)) + installed.orEmpty()
}
}
/** The launcher's groups, in the order they're laid out. */
@@ -240,7 +243,7 @@ internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: L
/** An app by the id a pin or the running list knows it by, built-in or installed. */
internal fun findEntry(id: String): Entry? =
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
BuiltInApps.everyEntry.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
@@ -58,6 +58,7 @@ import kotlinx.coroutines.withContext
fun CarSettingsScreen() {
val scale by CarSettings.scale.collectAsState()
val open by CarSettings.openOnConnect.collectAsState()
val launcher by LauncherMode.active.collectAsState()
val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState()
val navBar by CarSettings.navBarMode.collectAsState()
@@ -109,7 +110,7 @@ fun CarSettingsScreen() {
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_open_on_connect),
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) },
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
open,
) { CarSettings.setOpenOnConnect(it) }
}
@@ -125,7 +125,12 @@ fun CarUi() {
val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark, darkStyle)
val base = LocalDensity.current
val insets by CarEnvironment.insets.collectAsState()
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
val navigator = remember {
// A start screen picked for the car may be one launcher mode hides, like Screen sharing.
val start = CarSettings.openOnConnect.value
val shown = CarApps.shown(LauncherMode.active.value, LauncherMode.canCall(appContext)).any { it.id == start }
CarNavigator(if (shown) start else CarSettings.HOME)
}
val controlsWake = remember { MapControlsWake() }
LaunchedEffect(controlsWake) {
while (true) {
@@ -0,0 +1,79 @@
package app.seb3thehacker.gearslip.car
import android.app.ActivityOptions
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import android.view.Display
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.LauncherActivity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Gearslip as the home screen of the device it runs on: a head unit that runs Android itself, or
* a spare phone or tablet on the dash. The car UI fills the device's own screen, with no car in
* between. Off until it's turned on in Settings, so a phone never lists Gearslip as a home app.
*/
object LauncherMode {
private val _active = MutableStateFlow(false)
/** The car UI is up on this device's own screen, as its home screen. */
val active: StateFlow<Boolean> = _active
internal fun setActive(on: Boolean) {
_active.value = on
}
private fun component(context: Context) = ComponentName(context, LauncherActivity::class.java)
/** Whether Android may offer Gearslip as the home app. */
fun enabled(context: Context): Boolean =
context.packageManager.getComponentEnabledSetting(component(context)) ==
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
fun setEnabled(context: Context, on: Boolean) {
context.packageManager.setComponentEnabledSetting(
component(context),
if (on) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP,
)
GearslipLog.i("launcher mode ${if (on) "on" else "off"}")
}
/** Android's own "Default home app" choice, where Gearslip is picked once it's offered. */
fun openHomeChoice(context: Context) {
runCatching {
context.startActivity(Intent(Settings.ACTION_HOME_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}.onFailure { GearslipLog.w("launcher mode: no home app settings on this device (${it.message})") }
}
/** Head units and most tablets have no phone of their own to call with. */
fun canCall(context: Context): Boolean {
val feature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
PackageManager.FEATURE_TELEPHONY_CALLING
} else {
@Suppress("DEPRECATION")
PackageManager.FEATURE_TELEPHONY
}
return context.packageManager.hasSystemFeature(feature)
}
/**
* Opens an ordinary app full screen, Gearslip's own phone screens included. The car UI lives
* on a display of its own, and a context from it would start the app there, which Android
* refuses; the application context and the device's own display put it on the real screen.
*/
fun launch(context: Context, packageName: String) {
val app = context.applicationContext
val intent = app.packageManager.getLaunchIntentForPackage(packageName) ?: return
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
runCatching { app.startActivity(intent, options.toBundle()) }
.onFailure { GearslipLog.w("launcher mode: couldn't open $packageName (${it.message})") }
}
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search
@@ -83,11 +84,28 @@ internal object BuiltInApps {
val VehicleData = BuiltInApp("vehicle", "Vehicle data", R.string.car_vehicle_data, Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
val Settings = BuiltInApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
/** Launcher mode only: every app on the device, opened full screen. */
val AllApps = BuiltInApp("allapps", "All apps", R.string.app_all_apps, Icons.Filled.List, CarScreen.App("allapps")) { it.open("allapps") }
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
val Exit = BuiltInApp("exit", "Exit", R.string.app_exit, CarGlyph, screen = null) { CarFocus.exitToCar() }
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit, AllApps)
private val byApp = all.associateWith { Entry(it.label, icon = null, builtIn = it) }
val everyEntry: List<Entry> = byApp.values.toList()
/**
* The ones that make sense where the car UI is showing. As a device's own home screen there
* is no car to share the screen with or exit to, and no phone line on most head units.
*/
fun entries(launcher: Boolean, canCall: Boolean): List<Entry> = byApp.filterKeys { app ->
when (app) {
AllApps -> launcher
ScreenSharing, Exit -> !launcher
Phone -> !launcher || canCall
else -> true
}
}.values.toList()
}
/** The name a driver sees: translated for Gearslip's own apps, the app's own name otherwise. */
@@ -106,7 +106,7 @@ private fun TouchRelayWarning() {
}
@Composable
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
internal fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
GsIconBox(
onClick = onClick,
modifier = Modifier.height(120.dp),
@@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -60,7 +61,6 @@ import app.seb3thehacker.gearslip.car.CarUi
fun CarPreviewScreen(onBack: () -> Unit) {
val context = LocalContext.current
val frame by CarEnvironment.frame.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.
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -69,8 +69,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
val (width, height, density) = previewFrame(context, frame, previewSize, custom)
val projector = remember { ScreenProjector(context) }
// The preview is something to watch, like a map on a dash mount, so the phone stays awake on
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
val view = androidx.compose.ui.platform.LocalView.current
@@ -79,64 +77,20 @@ fun CarPreviewScreen(onBack: () -> Unit) {
onDispose { view.keepScreenOn = false }
}
DisposableEffect(width, height, density) {
CarEnvironment.start(context)
// No surface yet: the SurfaceView below hands its own over once it exists, and the
// display draws straight into it. Copying each frame into a Bitmap instead cost two
// full-size allocations per frame on the main thread, and taps lagged with the size.
projector.start(null, width, height, density, name = "gearslip-preview") {
androidx.compose.runtime.CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
}
onDispose { projector.stop() }
// A launcher already shows the car UI on this screen, and two of it can't run at once.
val launcher by app.seb3thehacker.gearslip.car.LauncherMode.active.collectAsStateWithLifecycle()
// Held here, not in the frame, so turning the phone between the two layouts keeps the car UI running.
val projector = if (launcher) null else rememberCarProjector(width, height, density, "gearslip-preview") {
CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
}
// The frame is drawn the same way in both layouts; only what surrounds it differs.
val frameContent: @Composable (Modifier) -> Unit = { modifier ->
Box(
modifier
.aspectRatio(width.toFloat() / height)
.background(Color.Black)
.onSizeChanged { viewSize = it.width to it.height }
.pointerInteropFilter { event ->
val (viewWidth, viewHeight) = viewSize
if (viewWidth > 0 && viewHeight > 0) {
projector.dispatchTouch(
event.actionMasked.toCarAction(),
event.actionIndex,
List(event.pointerCount) { i ->
TouchPoint(
event.getPointerId(i),
event.getX(i) * width / viewWidth,
event.getY(i) * height / viewHeight,
)
},
)
}
true
},
contentAlignment = Alignment.Center,
) {
androidx.compose.runtime.key(width, height) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { viewContext ->
android.view.SurfaceView(viewContext).apply {
// The car's own size; the compositor scales it to the view.
holder.setFixedSize(width, height)
holder.addCallback(object : android.view.SurfaceHolder.Callback {
override fun surfaceCreated(holder: android.view.SurfaceHolder) =
projector.setSurface(holder.surface)
override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) =
projector.setSurface(holder.surface)
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) =
projector.setSurface(null)
})
}
},
modifier = Modifier.fillMaxSize(),
)
}
if (projector == null) {
Text("Gearslip is this device's home screen, so the car UI is already showing there.", modifier)
} else {
CarFrame(projector, width, height, modifier.aspectRatio(width.toFloat() / height))
}
}
@@ -171,6 +125,80 @@ fun CarPreviewScreen(onBack: () -> Unit) {
}
}
/**
* Runs [content] on a virtual display [width] x [height] pixels big, the way the car gets it,
* until it leaves the composition. [CarFrame] shows it.
*/
@Composable
internal fun rememberCarProjector(width: Int, height: Int, density: Int, name: String, content: @Composable () -> Unit): ScreenProjector {
val context = LocalContext.current
val projector = remember { ScreenProjector(context) }
val latestContent by androidx.compose.runtime.rememberUpdatedState(content)
DisposableEffect(width, height, density) {
CarEnvironment.start(context)
// No surface yet: the SurfaceView in CarFrame hands its own over once it exists, and the
// display draws straight into it. Copying each frame into a Bitmap instead cost two
// full-size allocations per frame on the main thread, and taps lagged with the size.
projector.start(null, width, height, density, name = name) { latestContent() }
onDispose { projector.stop() }
}
return projector
}
/**
* The [projector]'s display through a SurfaceView, with touches scaled back to the frame's own
* [width] x [height]. The car preview letterboxes it; launcher mode fills the screen with it.
*/
@Composable
internal fun CarFrame(projector: ScreenProjector, width: Int, height: Int, modifier: Modifier) {
var viewSize by remember { mutableStateOf(0 to 0) }
Box(
modifier
.background(Color.Black)
.onSizeChanged { viewSize = it.width to it.height }
.pointerInteropFilter { event ->
val (viewWidth, viewHeight) = viewSize
if (viewWidth > 0 && viewHeight > 0) {
projector.dispatchTouch(
event.actionMasked.toCarAction(),
event.actionIndex,
List(event.pointerCount) { i ->
TouchPoint(
event.getPointerId(i),
event.getX(i) * width / viewWidth,
event.getY(i) * height / viewHeight,
)
},
)
}
true
},
contentAlignment = Alignment.Center,
) {
androidx.compose.runtime.key(width, height) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { viewContext ->
android.view.SurfaceView(viewContext).apply {
// The car's own size; the compositor scales it to the view.
holder.setFixedSize(width, height)
holder.addCallback(object : android.view.SurfaceHolder.Callback {
override fun surfaceCreated(holder: android.view.SurfaceHolder) =
projector.setSurface(holder.surface)
override fun surfaceChanged(holder: android.view.SurfaceHolder, format: Int, w: Int, h: Int) =
projector.setSurface(holder.surface)
override fun surfaceDestroyed(holder: android.view.SurfaceHolder) =
projector.setSurface(null)
})
}
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
/**
* Fills the phone screen with [content], centred and letterboxed.
*
@@ -28,6 +28,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -35,6 +36,7 @@ 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.NavBarMode
import app.seb3thehacker.gearslip.car.NightMode
@@ -73,7 +75,11 @@ private fun navBarChoices() = listOf(
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
)
@Composable
private fun openChoices() = listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) }
private fun openChoices(): List<Pair<String, String>> {
val context = LocalContext.current
val apps = remember(context) { CarApps.shown(LauncherMode.enabled(context), LauncherMode.canCall(context)) }
return listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + apps.map { it.id to stringResource(it.labelRes) }
}
/**
* The car's settings on the phone's home screen: one card per group with a summary of where it
@@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.ui
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.LauncherMode
import androidx.compose.runtime.withFrameNanos
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -33,6 +34,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Warning
@@ -230,6 +232,20 @@ private fun ConnectionSection() {
icon = Icons.Filled.Settings,
trailing = { Switch(checked = enabled, onCheckedChange = null) },
)
// For a head unit or a tablet on the dash: the car UI becomes the device's home screen.
var launcher by remember { mutableStateOf(LauncherMode.enabled(context)) }
SettingsRow(
stringResource(R.string.settings_use_as_launcher),
Modifier.toggleable(launcher, role = Role.Switch) {
launcher = it
LauncherMode.setEnabled(context, it)
// Android doesn't switch home apps by itself; this is where the driver picks Gearslip.
if (it) LauncherMode.openHomeChoice(context)
},
subtitle = stringResource(if (launcher) R.string.settings_use_as_launcher_on else R.string.settings_use_as_launcher_off),
icon = Icons.Filled.Home,
trailing = { Switch(checked = launcher, onCheckedChange = null) },
)
}
}
+6
View File
@@ -864,6 +864,12 @@
<string name="status_no_car_graphene">The phone is charging, but no car has answered. Check that USB-C port control, under Security &amp; privacy, allows data and isn\'t set to Charging-only.</string>
<string name="status_stuck_cable">The car hasn\'t shown Gearslip yet. Unplug the cable, wait a few seconds, and plug it back in. If it keeps happening, try another cable or USB port, and share the log in an issue.</string>
<string name="status_stuck_wireless">The car hasn\'t shown Gearslip yet. Turn the car off and on again, or connect with a cable. If it keeps happening, share the log in an issue.</string>
<string name="settings_use_as_launcher">Use as launcher</string>
<string name="settings_use_as_launcher_off">For a head unit or a tablet on the dash. Gearslip\'s car screen becomes this device\'s home screen.</string>
<string name="settings_use_as_launcher_on">Gearslip can be this device\'s home screen. Pick it under Default home app.</string>
<string name="app_all_apps">All apps</string>
<string name="launcher_in_car">This device is connected to a car, so Gearslip is on the car\'s screen.</string>
<string name="launcher_open_gearslip">Open Gearslip</string>
<string name="status_open_settings">Open settings</string>
<string name="status_connecting">Connecting</string>
<string name="status_projecting">Projecting</string>