mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
1 parent
07e398cf79
commit
75f43c3e3d
16 files changed
+476
-66
No files matched your search
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in new issue
Block a user