diff --git a/app/src/main/java/com/droiddeck/launcher/HomeApp.kt b/app/src/main/java/com/droiddeck/launcher/HomeApp.kt index 8be97a55c..c460825a0 100644 --- a/app/src/main/java/com/droiddeck/launcher/HomeApp.kt +++ b/app/src/main/java/com/droiddeck/launcher/HomeApp.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher +import android.app.ActivityOptions import android.app.role.RoleManager import android.graphics.Bitmap import android.content.ComponentName @@ -73,11 +74,19 @@ object HomeApp { return apps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label }) } - fun launch(context: Context, app: LaunchableApp) { + fun launch(context: Context, app: LaunchableApp, displayId: Int? = null) { val intent = Intent(Intent.ACTION_MAIN) .addCategory(Intent.CATEGORY_LAUNCHER) .setComponent(ComponentName(app.packageName, app.className)) - context.startActivity(intent) + if (displayId == null) { + context.startActivity(intent) + return + } + // Keep the target app's task separate from DroidDeck. Without this, Android can put the + // app on top of the caller's task and move the entire task to the chosen display. + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val options = ActivityOptions.makeBasic().setLaunchDisplayId(displayId) + context.startActivity(intent, options.toBundle()) } fun isActiveSteamSession(): Boolean = diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 2aa30e545..0617fde30 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -4,12 +4,14 @@ import android.Manifest import android.content.Intent import java.io.File import android.content.pm.PackageManager +import android.hardware.display.DisplayManager import android.net.Uri import android.provider.OpenableColumns import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.Log +import android.view.Display import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -55,6 +57,8 @@ import com.droiddeck.launcher.files.InAppFilePicker import com.droiddeck.launcher.session.SessionArtifacts import com.droiddeck.launcher.session.SessionState import com.droiddeck.launcher.session.GameStorage +import com.droiddeck.launcher.input.SecondScreenDisplay +import com.droiddeck.launcher.input.SecondScreenDisplays /** * The whole app outside a session: is the runtime installed, is there a newer one, frame @@ -187,6 +191,13 @@ class MainActivity : ComponentActivity() { private var homeAppSelected by mutableStateOf(false) private var defaultHomeLabel by mutableStateOf(null) private var androidApps by mutableStateOf>(emptyList()) + private var secondScreenDisplays by mutableStateOf>(emptyList()) + private lateinit var displayManager: DisplayManager + private val secondScreenDisplayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays() + override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays() + override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays() + } private val homeRoleRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { refreshHomeAppState() @@ -212,6 +223,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager theme = SessionPrefs.theme(this) backActionsInverted = SessionPrefs.backActionsInverted(this) setContent { @@ -238,6 +250,7 @@ class MainActivity : ComponentActivity() { isHomeApp = homeAppSelected, defaultHomeLabel = defaultHomeLabel, androidApps = androidApps, + secondScreenDisplays = secondScreenDisplays, packages = packageRows, packageCatalogLoading = catalogLoading, packageBusyId = pkgId, @@ -305,7 +318,7 @@ class MainActivity : ComponentActivity() { onPageBack = { settingsMode = null; showPerformance = false; showProtons = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onHomeApp = { manageHomeApp() }, - onAndroidApp = { app -> launchAndroidApp(app) }, + onAndroidApp = { app, displayId -> launchAndroidApp(app, displayId) }, onBackActionsInverted = { inverted -> SessionPrefs.setBackActionsInverted(this, inverted) backActionsInverted = inverted @@ -367,6 +380,7 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() refreshHomeAppState() + refreshSecondScreenDisplays() refresh() // Added games' art (a store lookup for what the folders lack) starts here, not only when // the cog opens. @@ -375,17 +389,35 @@ class MainActivity : ComponentActivity() { if (!busy) Thread({ checkCatalog() }, "catalog").start() } + override fun onStart() { + super.onStart() + displayManager.registerDisplayListener(secondScreenDisplayListener, ui) + refreshSecondScreenDisplays() + } + + override fun onStop() { + displayManager.unregisterDisplayListener(secondScreenDisplayListener) + super.onStop() + } + private fun refreshHomeAppState() { homeAppSelected = HomeApp.isDefault(this) defaultHomeLabel = HomeApp.defaultLabel(this) androidApps = if (homeAppSelected) HomeApp.launchableApps(this) else emptyList() } - private fun launchAndroidApp(app: HomeApp.LaunchableApp) { + private fun refreshSecondScreenDisplays() { + if (!::displayManager.isInitialized) return + secondScreenDisplays = SecondScreenDisplays.available(displayManager) + } + + private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) { try { - HomeApp.launch(this, app) + HomeApp.launch(this, app, displayId) } catch (_: Exception) { - android.widget.Toast.makeText(this, "Could not open ${app.label}", android.widget.Toast.LENGTH_SHORT).show() + val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen" + else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId" + android.widget.Toast.makeText(this, "Could not open ${app.label} on $target", android.widget.Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 93429711c..9e9c3a146 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -37,6 +37,7 @@ import com.droiddeck.launcher.input.OnScreenControls import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.PointerGestures import com.droiddeck.launcher.input.SecondScreenDisplay +import com.droiddeck.launcher.input.SecondScreenDisplays import com.droiddeck.launcher.input.SecondScreenMode import com.droiddeck.launcher.input.TouchpadGestures import com.droiddeck.launcher.runtime.LinuxRuntime @@ -249,12 +250,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() }, onSecondScreenMode = ::selectSecondScreenMode, onSecondScreenDisplay = ::selectSecondScreenDisplay, - onLaunchAndroidApp = { app -> + onLaunchAndroidApp = { app, displayId -> drawerOpen = false try { - HomeApp.launch(this@SessionActivity, app) + HomeApp.launch(this@SessionActivity, app, displayId) } catch (_: Exception) { - Toast.makeText(this@SessionActivity, "Could not open ${app.label}", Toast.LENGTH_SHORT).show() + val target = if (displayId == null || displayId == Display.DEFAULT_DISPLAY) "the primary screen" + else secondScreenDisplays.firstOrNull { it.id == displayId }?.label ?: "display $displayId" + Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show() } }, onBackground = { drawerOpen = false; moveTaskToBack(true) }, @@ -848,14 +851,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun refreshSecondScreenDisplays() { if (!::displayManager.isInitialized) return val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId - val candidates = displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION) - .asSequence() - .filter { it.isValid && it.displayId != primaryId && (it.flags and Display.FLAG_PRESENTATION) != 0 } - .map { target -> - val mode = target.mode - SecondScreenDisplay(target.displayId, "${target.name} · ${mode.physicalWidth}×${mode.physicalHeight}") - } - .toList() + val candidates = SecondScreenDisplays.available(displayManager, primaryId) val oldIds = secondScreenDisplays.map { it.id }.toSet() secondScreenDisplays = candidates val newIds = candidates.map { it.id }.toSet() diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt index 86daa4620..85fc94ca8 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt @@ -1,5 +1,8 @@ package com.droiddeck.launcher.input +import android.hardware.display.DisplayManager +import android.view.Display + /** The control surface shown on a secondary Android display during a Linux session. */ enum class SecondScreenMode(val id: String, val label: String) { NONE("none", "None"), @@ -8,3 +11,19 @@ enum class SecondScreenMode(val id: String, val label: String) { } data class SecondScreenDisplay(val id: Int, val label: String) + +/** The presentation-capable displays shared by the session controls and Android app launcher. */ +object SecondScreenDisplays { + fun available(displayManager: DisplayManager, primaryDisplayId: Int = Display.DEFAULT_DISPLAY): List = + displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION) + .asSequence() + .filter { it.isValid && it.displayId != primaryDisplayId && (it.flags and Display.FLAG_PRESENTATION) != 0 } + .map { target -> + val mode = target.mode + SecondScreenDisplay( + target.displayId, + "${target.name} · ${mode.physicalWidth}×${mode.physicalHeight}", + ) + } + .toList() +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index ea229d13c..0f949f56c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.focus.onFocusChanged import android.graphics.Bitmap import android.os.Build import android.provider.Settings +import android.view.Display import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -126,8 +127,10 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil.compose.AsyncImage import com.droiddeck.launcher.R +import com.droiddeck.launcher.HomeApp import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen +import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.session.SessionPrefs import java.io.File import kotlin.math.roundToInt @@ -155,7 +158,8 @@ class FrontEndState( val theme: String = Themes.PAPER, val isHomeApp: Boolean = false, val defaultHomeLabel: String? = null, - val androidApps: List = emptyList(), + val androidApps: List = emptyList(), + val secondScreenDisplays: List = emptyList(), val packages: List? = null, val packageCatalogLoading: Boolean = false, val packageBusyId: String? = null, @@ -191,7 +195,7 @@ class FrontEndActions( val onPageBack: () -> Unit = {}, val onTheme: (String) -> Unit = {}, val onHomeApp: () -> Unit = {}, - val onAndroidApp: (com.droiddeck.launcher.HomeApp.LaunchableApp) -> Unit = {}, + val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> }, val onBackActionsInverted: (Boolean) -> Unit = {}, val onCheckLatestBuild: () -> Unit = {}, ) @@ -305,6 +309,7 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () - @Composable private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) { var selected by rememberSaveable { mutableStateOf("steam") } + var appToChooseDisplay by remember { mutableStateOf(null) } val colors = MaterialTheme.colorScheme val ctx = LocalContext.current BackHandler(enabled = s.pageKey != null && page != null) { a.onPageBack() } @@ -368,9 +373,31 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } } .focusGroup() - val content: @Composable (Modifier) -> Unit = { m -> Pane(s, selected, a, page, m.then(paneFocus)) { selected = it } } + val content: @Composable (Modifier) -> Unit = { m -> + Pane(s, selected, a, page, m.then(paneFocus), { selected = it }) { app -> + if (s.secondScreenDisplays.isEmpty()) a.onAndroidApp(app, null) + else appToChooseDisplay = app + } + } if (wide) Row(modifier = Modifier.fillMaxSize()) { rail(); content(Modifier.weight(1f).fillMaxHeight()) } else Column(modifier = Modifier.fillMaxSize()) { rail(); content(Modifier.weight(1f).fillMaxWidth()) } + + appToChooseDisplay?.let { app -> + val secondaryDisplay = s.secondScreenDisplays.firstOrNull() + ChooseAppDisplayDialog( + app = app, + secondaryDisplay = secondaryDisplay, + onPrimary = { + appToChooseDisplay = null + a.onAndroidApp(app, Display.DEFAULT_DISPLAY) + }, + onSecondary = { + appToChooseDisplay = null + secondaryDisplay?.let { a.onAndroidApp(app, it.id) } + }, + onDismiss = { appToChooseDisplay = null }, + ) + } } } @@ -551,7 +578,10 @@ private fun NavItem( @Composable -private fun Pane(s: FrontEndState, selected: String, a: FrontEndActions, page: (@Composable () -> Unit)?, modifier: Modifier, onSelect: (String) -> Unit) { +private fun Pane( + s: FrontEndState, selected: String, a: FrontEndActions, page: (@Composable () -> Unit)?, modifier: Modifier, + onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit, +) { Box(modifier = modifier) { val wash: Pair = when { s.pageKey != null && page != null -> null to 250f @@ -572,7 +602,7 @@ private fun Pane(s: FrontEndState, selected: String, a: FrontEndActions, page: ( .apply { targetContentZIndex = 1f } }, label = "pane", - ) { key -> if (page != null && key == s.pageKey) page() else Content(s, key, a, Modifier.fillMaxSize(), onSelect) } + ) { key -> if (page != null && key == s.pageKey) page() else Content(s, key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick) } } } @@ -642,7 +672,10 @@ private fun romFor(s: FrontEndState, selected: String): Pair Unit) { +private fun Content( + s: FrontEndState, selected: String, a: FrontEndActions, modifier: Modifier, + onSelect: (String) -> Unit, onAndroidAppClick: (HomeApp.LaunchableApp) -> Unit, +) { val colors = MaterialTheme.colorScheme Column(modifier = modifier.padding(horizontal = 22.dp, vertical = 18.dp)) { when { @@ -658,7 +691,7 @@ private fun Content(s: FrontEndState, selected: String, a: FrontEndActions, modi null, null, "android:${app.packageName}", - onClick = { a.onAndroidApp(app) }, + onClick = { onAndroidAppClick(app) }, iconBitmap = app.icon, ) }) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt index 5fb8fc0e6..f70136020 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt @@ -12,6 +12,34 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.droiddeck.launcher.HomeApp +import com.droiddeck.launcher.input.SecondScreenDisplay + +@Composable +fun ChooseAppDisplayDialog( + app: HomeApp.LaunchableApp, + secondaryDisplay: SecondScreenDisplay?, + onPrimary: () -> Unit, + onSecondary: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Launch ${app.label}") }, + text = { + Text( + secondaryDisplay?.let { "Choose a screen. Secondary: ${it.label}." } + ?: "The secondary display is no longer available.", + ) + }, + confirmButton = { TextButton(onClick = onPrimary) { Text("Primary screen") } }, + dismissButton = { + TextButton(onClick = onSecondary, enabled = secondaryDisplay != null) { + Text("Secondary screen") + } + }, + ) +} @Composable fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 70be6cef0..b3eab4b6c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -1,5 +1,6 @@ package com.droiddeck.launcher.ui +import android.view.Display import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -195,7 +196,7 @@ class DrawerActions( val onFillScreen: (Boolean) -> Unit = {}, val onSecondScreenMode: (SecondScreenMode) -> Unit, val onSecondScreenDisplay: (Int) -> Unit, - val onLaunchAndroidApp: (HomeApp.LaunchableApp) -> Unit, + val onLaunchAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit, val onBackground: () -> Unit, val onStop: () -> Unit, val onClose: () -> Unit, @@ -208,6 +209,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { val pal = LocalPalette.current val host = rememberMenuHost() var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } + var appToChooseDisplay by remember { mutableStateOf(null) } val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil") // A controller starts on the drawer's first control, highlighted, as on the app's main screen: // the STEAM button, or in a desktop session the first setting. @@ -336,7 +338,11 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { ) } }, - ) { a.onLaunchAndroidApp(app) } + ) { + host.open = null + if (a.secondScreenDisplays.isEmpty()) a.onLaunchAndroidApp(app, null) + else appToChooseDisplay = app + } } } } @@ -434,6 +440,24 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { } } } + + appToChooseDisplay?.let { app -> + val secondaryDisplay = a.secondScreenDisplays.firstOrNull { it.id == a.selectedSecondScreenDisplay } + ?: a.secondScreenDisplays.firstOrNull() + ChooseAppDisplayDialog( + app = app, + secondaryDisplay = secondaryDisplay, + onPrimary = { + appToChooseDisplay = null + a.onLaunchAndroidApp(app, Display.DEFAULT_DISPLAY) + }, + onSecondary = { + appToChooseDisplay = null + secondaryDisplay?.let { a.onLaunchAndroidApp(app, it.id) } + }, + onDismiss = { appToChooseDisplay = null }, + ) + } } @Composable