Merge pull request #33 from The412Banner/feat/secondary-screen-app-launch

Add per-launch Android display selection
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-09-24 11:14:27 -04:00
commit b49e2753df
7 files changed
+167 -26

No files matched your search

@@ -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 =
@@ -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<String?>(null)
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(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()
}
}
@@ -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()
@@ -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<SecondScreenDisplay> =
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()
}
@@ -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<com.droiddeck.launcher.HomeApp.LaunchableApp> = emptyList(),
val androidApps: List<HomeApp.LaunchableApp> = emptyList(),
val secondScreenDisplays: List<SecondScreenDisplay> = emptyList(),
val packages: List<PackageRow>? = 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<HomeApp.LaunchableApp?>(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<File?, Float> = 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<Library.Emulator, L
}
@Composable
private fun Content(s: FrontEndState, selected: String, a: FrontEndActions, modifier: Modifier, onSelect: (String) -> 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,
)
})
@@ -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) {
@@ -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<HomeApp.LaunchableApp?>(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