mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
Merge pull request #33 from The412Banner/feat/secondary-screen-app-launch
Add per-launch Android display selection
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user