From 3059023ba355f9b3adb93b1f14d0b3be3c5fbefe Mon Sep 17 00:00:00 2001 From: saicharankandukuri Date: Fri, 18 Sep 2026 13:25:10 +0530 Subject: [PATCH 1/3] feat(desktop): add floating mouse controls --- .../org/randomcoder/udroid/ui/DesktopPage.kt | 333 +++++++++++++++++- .../udroid/x11/TrackpadGestureController.java | 14 +- .../udroid/x11/X11DisplayView.java | 31 +- .../udroid/ui/DesktopInputPaletteTest.kt | 30 ++ .../x11/TrackpadGestureControllerTest.java | 13 + 5 files changed, 405 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt diff --git a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt index 4897cd1..83b6562 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt @@ -1,20 +1,34 @@ package org.randomcoder.udroid.ui import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.icons.rounded.Keyboard +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.LockOpen +import androidx.compose.material.icons.rounded.Mouse import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -22,16 +36,30 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import kotlin.math.roundToInt +import com.termux.x11.input.InputStub import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import org.randomcoder.udroid.runtime.RuntimeSupervisorService @@ -51,15 +79,27 @@ fun DesktopPage( var controlsExpanded by remember { mutableStateOf(!settings.startControlsCollapsed) } + var inputPaletteVisible by rememberSaveable { mutableStateOf(false) } + var inputPaletteLocked by rememberSaveable { mutableStateOf(false) } + var heldMouseButton by remember { mutableStateOf(null) } + var inputPaletteX by rememberSaveable { mutableIntStateOf(UNSET_PALETTE_POSITION) } + var inputPaletteY by rememberSaveable { mutableIntStateOf(UNSET_PALETTE_POSITION) } var showSettings by remember { mutableStateOf(false) } + var displayView by remember { mutableStateOf(null) } + val releaseHeldMouseButton = { + heldMouseButton?.let { displayView?.setMouseButton(it, false) } + heldMouseButton = null + } BackHandler { if (showSettings) { showSettings = false + } else if (inputPaletteVisible) { + releaseHeldMouseButton() + inputPaletteVisible = false } else { onExit() } } - var displayView by remember { mutableStateOf(null) } var status by remember { mutableStateOf("Waiting for the supervised X11 renderer") } val updateSettings: (X11Settings) -> Unit = { updated -> settings = settingsStore.save(updated) @@ -104,8 +144,16 @@ fun DesktopPage( if (controlsExpanded) { DesktopControlBar( status = status, - onExit = onExit, + inputPaletteVisible = inputPaletteVisible, + onExit = { + releaseHeldMouseButton() + onExit() + }, onKeyboard = { displayView?.showKeyboard() }, + onInputPalette = { + if (inputPaletteVisible) releaseHeldMouseButton() + inputPaletteVisible = !inputPaletteVisible + }, onSettings = { showSettings = true }, onCollapse = { controlsExpanded = false }, ) @@ -115,19 +163,43 @@ fun DesktopPage( onExpand = { controlsExpanded = true }, ) } - AndroidView( - factory = { context -> - X11DisplayView(context).also { - it.applySettings(settings) - displayView = it - } - }, - update = { it.applySettings(settings) }, + Box( modifier = Modifier .fillMaxWidth() .weight(1f), - ) + ) { + AndroidView( + factory = { context -> + X11DisplayView(context).also { + it.applySettings(settings) + displayView = it + } + }, + update = { it.applySettings(settings) }, + modifier = Modifier.fillMaxSize(), + ) + if (inputPaletteVisible) { + DesktopInputPalette( + locked = inputPaletteLocked, + heldMouseButton = heldMouseButton, + positionX = inputPaletteX, + positionY = inputPaletteY, + onLockedChange = { inputPaletteLocked = it }, + onPositionChange = { x, y -> + inputPaletteX = x + inputPaletteY = y + }, + onMouseButton = { button -> + val next = nextHeldMouseButton(heldMouseButton, button) + heldMouseButton?.let { displayView?.setMouseButton(it, false) } + next?.let { displayView?.setMouseButton(it, true) } + heldMouseButton = next + }, + onScroll = { displayView?.scrollMouse(it) }, + ) + } + } } } @@ -143,8 +215,10 @@ fun DesktopPage( @Composable private fun DesktopControlBar( status: String, + inputPaletteVisible: Boolean, onExit: () -> Unit, onKeyboard: () -> Unit, + onInputPalette: () -> Unit, onSettings: () -> Unit, onCollapse: () -> Unit, ) { @@ -188,6 +262,23 @@ private fun DesktopControlBar( tint = UdroidTerminalText, ) } + IconButton(onClick = onInputPalette) { + Icon( + imageVector = Icons.Rounded.Mouse, + contentDescription = + if (inputPaletteVisible) { + "Hide mouse controls" + } else { + "Show mouse controls" + }, + tint = + if (inputPaletteVisible) { + UdroidTerminalGreen + } else { + UdroidTerminalText + }, + ) + } IconButton(onClick = onSettings) { Icon( imageVector = Icons.Rounded.Settings, @@ -205,6 +296,226 @@ private fun DesktopControlBar( } } +@Composable +private fun DesktopInputPalette( + locked: Boolean, + heldMouseButton: Int?, + positionX: Int, + positionY: Int, + onLockedChange: (Boolean) -> Unit, + onPositionChange: (Int, Int) -> Unit, + onMouseButton: (Int) -> Unit, + onScroll: (Float) -> Unit, +) { + var containerSize by remember { mutableStateOf(IntSize.Zero) } + var paletteSize by remember { mutableStateOf(IntSize.Zero) } + val margin = 12.dp + val marginPixels = with(androidx.compose.ui.platform.LocalDensity.current) { margin.roundToPx() } + val latestPositionX = rememberUpdatedState(positionX) + val latestPositionY = rememberUpdatedState(positionY) + + LaunchedEffect(containerSize, paletteSize) { + if (containerSize == IntSize.Zero || paletteSize == IntSize.Zero) return@LaunchedEffect + val maxX = (containerSize.width - paletteSize.width).coerceAtLeast(0) + val maxY = (containerSize.height - paletteSize.height).coerceAtLeast(0) + val clampedX = + if (positionX == UNSET_PALETTE_POSITION) { + (maxX - marginPixels).coerceAtLeast(0) + } else { + clampInputPaletteCoordinate(positionX, maxX) + } + val clampedY = + if (positionY == UNSET_PALETTE_POSITION) { + marginPixels.coerceAtMost(maxY) + } else { + clampInputPaletteCoordinate(positionY, maxY) + } + if (clampedX != positionX || clampedY != positionY) { + onPositionChange(clampedX, clampedY) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .onSizeChanged { containerSize = it }, + ) { + Surface( + modifier = + Modifier + .offset { + IntOffset( + positionX.coerceAtLeast(0), + positionY.coerceAtLeast(0), + ) + }.onSizeChanged { paletteSize = it }, + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp, + shadowElevation = 3.dp, + ) { + Column(modifier = Modifier.padding(8.dp)) { + Row( + modifier = + Modifier + .width(184.dp) + .height(36.dp) + .then( + if (locked) { + Modifier + } else { + Modifier.pointerInput(containerSize, paletteSize) { + var dragX = 0f + var dragY = 0f + detectDragGestures( + onDragStart = { + dragX = latestPositionX.value.toFloat() + dragY = latestPositionY.value.toFloat() + }, + ) { change, dragAmount -> + change.consume() + val maxX = + (containerSize.width - paletteSize.width) + .coerceAtLeast(0) + val maxY = + (containerSize.height - paletteSize.height) + .coerceAtLeast(0) + dragX = + accumulateInputPaletteDrag(dragX, dragAmount.x, maxX) + dragY = + accumulateInputPaletteDrag(dragY, dragAmount.y, maxY) + onPositionChange(dragX.roundToInt(), dragY.roundToInt()) + } + } + }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.DragHandle, + contentDescription = if (locked) null else "Move mouse controls", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Mouse", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 4.dp), + ) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = { onLockedChange(!locked) }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = + if (locked) Icons.Rounded.Lock else Icons.Rounded.LockOpen, + contentDescription = + if (locked) { + "Unlock mouse controls" + } else { + "Lock mouse controls" + }, + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + MouseButton( + label = "L", + description = "Hold left mouse button", + selected = heldMouseButton == InputStub.BUTTON_LEFT, + onClick = { onMouseButton(InputStub.BUTTON_LEFT) }, + ) + MouseButton( + label = "M", + description = "Hold middle mouse button", + selected = heldMouseButton == InputStub.BUTTON_MIDDLE, + onClick = { onMouseButton(InputStub.BUTTON_MIDDLE) }, + ) + MouseButton( + label = "R", + description = "Hold right mouse button", + selected = heldMouseButton == InputStub.BUTTON_RIGHT, + onClick = { onMouseButton(InputStub.BUTTON_RIGHT) }, + ) + Spacer(modifier = Modifier.width(4.dp)) + FilledTonalIconButton( + onClick = { onScroll(MOUSE_SCROLL_STEP) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowUp, + contentDescription = "Scroll up", + ) + } + FilledTonalIconButton( + onClick = { onScroll(-MOUSE_SCROLL_STEP) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = "Scroll down", + ) + } + } + } + } + } +} + +@Composable +private fun MouseButton( + label: String, + description: String, + selected: Boolean, + onClick: () -> Unit, +) { + Surface( + modifier = + Modifier + .size(40.dp) + .clickable( + role = Role.Button, + onClickLabel = if (selected) "Release" else "Hold", + onClick = onClick, + ).semantics { + role = Role.Button + contentDescription = description + }, + shape = RoundedCornerShape(20.dp), + color = + if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + Color.Transparent + }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + } + } +} + +internal fun clampInputPaletteCoordinate( + value: Int, + maximum: Int, +): Int = value.coerceIn(0, maximum.coerceAtLeast(0)) + +internal fun nextHeldMouseButton( + current: Int?, + tapped: Int, +): Int? = if (current == tapped) null else tapped + +internal fun accumulateInputPaletteDrag( + current: Float, + delta: Float, + maximum: Int, +): Float = (current + delta).coerceIn(0f, maximum.coerceAtLeast(0).toFloat()) + +private const val UNSET_PALETTE_POSITION = Int.MIN_VALUE +private const val MOUSE_SCROLL_STEP = 120f + @Composable private fun CollapsedDesktopControlBar( attached: Boolean, diff --git a/app/src/main/java/org/randomcoder/udroid/x11/TrackpadGestureController.java b/app/src/main/java/org/randomcoder/udroid/x11/TrackpadGestureController.java index ac740d0..af1f11e 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/TrackpadGestureController.java +++ b/app/src/main/java/org/randomcoder/udroid/x11/TrackpadGestureController.java @@ -45,6 +45,7 @@ final class TrackpadGestureController { private float lastTapX; private float lastTapY; private float speed = 1f; + private boolean externalMouseButtonHeld; TrackpadGestureController( X11InputSink sink, @@ -68,6 +69,10 @@ void setSpeed(float speed) { this.speed = Math.max(0.25f, Math.min(3f, speed)); } + void setExternalMouseButtonHeld(boolean held) { + externalMouseButtonHeld = held; + } + boolean onTouchEvent(MotionEvent event) { int action = event.getActionMasked(); int actionIndex = event.getActionIndex(); @@ -143,14 +148,15 @@ void handleDown(long eventTime, int pointerId, float x, float y) { lastCentroidX = x; lastCentroidY = y; maxPointerCount = 1; - longPressEligible = true; + longPressEligible = !externalMouseButtonHeld; setPointerInitialPosition(pointerId, x, y); long interval = eventTime - lastTapTimeMillis; float deltaX = x - lastTapX; float deltaY = y - lastTapY; doubleTapDragCandidate = - lastTapTimeMillis != Long.MIN_VALUE && + !externalMouseButtonHeld && + lastTapTimeMillis != Long.MIN_VALUE && interval >= 0 && interval <= doubleTapTimeoutMillis && deltaX * deltaX + deltaY * deltaY <= doubleTapSlopSquared; @@ -302,7 +308,9 @@ void handleUp( } clearPointer(pointerId); - if (dragActive) { + if (externalMouseButtonHeld) { + clearTapHistory(); + } else if (dragActive) { sink.sendMouseEvent(0, 0, InputStub.BUTTON_LEFT, false, true); clearTapHistory(); } else if (!tapCancelled && diff --git a/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java b/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java index bf1f7bc..9e7f945 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java +++ b/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java @@ -51,6 +51,7 @@ public final class X11DisplayView extends SurfaceView private boolean imeHasCommittedText; private CharSequence composingText; private int pressedMouseButton = BUTTON_UNDEFINED; + private int paletteMouseButton = BUTTON_UNDEFINED; private int viewportLeft; private int viewportTop; private int viewportWidth; @@ -313,7 +314,9 @@ public boolean onTouchEvent(MotionEvent event) { false, false ); - inputSink.sendMouseEvent(0, 0, BUTTON_LEFT, true, false); + if (paletteMouseButton == BUTTON_UNDEFINED) { + inputSink.sendMouseEvent(0, 0, BUTTON_LEFT, true, false); + } return true; case MotionEvent.ACTION_MOVE: mapToGuest(event.getX(0), event.getY(0), mappedPoint); @@ -333,7 +336,9 @@ public boolean onTouchEvent(MotionEvent event) { false, false ); - releaseTouchButton(); + if (paletteMouseButton == BUTTON_UNDEFINED) { + releaseTouchButton(); + } performClick(); return true; case MotionEvent.ACTION_CANCEL: @@ -453,6 +458,26 @@ public void showKeyboard() { post(() -> inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)); } + public void setMouseButton(int button, boolean pressed) { + if (!rendererAttached || button < BUTTON_LEFT || button > BUTTON_RIGHT) return; + requestFocus(); + inputSink.sendMouseEvent(0, 0, button, pressed, true); + if (pressed) { + paletteMouseButton = button; + } else if (paletteMouseButton == button) { + paletteMouseButton = BUTTON_UNDEFINED; + } + trackpadGestures.setExternalMouseButtonHeld( + paletteMouseButton != BUTTON_UNDEFINED + ); + } + + public void scrollMouse(float verticalDelta) { + if (!rendererAttached || verticalDelta == 0) return; + requestFocus(); + inputSink.sendMouseWheelEvent(0, verticalDelta); + } + private boolean handleMouseEvent(MotionEvent event) { mapToGuest(event.getX(), event.getY(), mappedPoint); inputSink.sendMouseEvent( @@ -534,9 +559,11 @@ private void releaseTouchButton() { private void releaseAllInput() { trackpadGestures.cancel(); + trackpadGestures.setExternalMouseButtonHeld(false); nativeTouches.cancel(); inputSink.releaseAllInput(); pressedMouseButton = BUTTON_UNDEFINED; + paletteMouseButton = BUTTON_UNDEFINED; } @Override diff --git a/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt new file mode 100644 index 0000000..3260944 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt @@ -0,0 +1,30 @@ +package org.randomcoder.udroid.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DesktopInputPaletteTest { + @Test + fun positionIsClampedInsideTheDesktop() { + assertEquals(0, clampInputPaletteCoordinate(-20, 300)) + assertEquals(180, clampInputPaletteCoordinate(180, 300)) + assertEquals(300, clampInputPaletteCoordinate(420, 300)) + assertEquals(0, clampInputPaletteCoordinate(20, -1)) + } + + @Test + fun mouseButtonTapHoldsSwitchesAndReleases() { + assertEquals(1, nextHeldMouseButton(null, 1)) + assertEquals(3, nextHeldMouseButton(1, 3)) + assertEquals(null, nextHeldMouseButton(3, 3)) + } + + @Test + fun dragAccumulatesEveryMotionDelta() { + val firstMove = accumulateInputPaletteDrag(100f, 8f, 300) + val secondMove = accumulateInputPaletteDrag(firstMove, 7f, 300) + + assertEquals(115f, secondMove) + assertEquals(300f, accumulateInputPaletteDrag(secondMove, 500f, 300)) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/x11/TrackpadGestureControllerTest.java b/app/src/test/java/org/randomcoder/udroid/x11/TrackpadGestureControllerTest.java index 8ca000d..a9a0745 100644 --- a/app/src/test/java/org/randomcoder/udroid/x11/TrackpadGestureControllerTest.java +++ b/app/src/test/java/org/randomcoder/udroid/x11/TrackpadGestureControllerTest.java @@ -54,6 +54,19 @@ public void oneFingerMoveIsRelativeAndDoesNotClick() { ); } + @Test + public void heldPaletteButtonLeavesTouchAsPointerMotionOnly() { + controller.setExternalMouseButtonHeld(true); + controller.handleDown(0, 0, 100, 200); + controller.handleMove(50, 1, 120, 190, 120, 190, 0, 500); + controller.handleUp(80, 0, 120, 190, 500); + + assertEquals( + List.of("mouse 20.0 -10.0 0 false true"), + recording.events + ); + } + @Test public void twoFingerTapEmitsRightClick() { controller.handleDown(0, 2, 10, 10); From 127679c438915ed2b783113a4ed3aca2642252cc Mon Sep 17 00:00:00 2001 From: saicharankandukuri Date: Fri, 18 Sep 2026 15:06:00 +0530 Subject: [PATCH 2/3] fix(desktop): support simultaneous mouse controls --- .../org/randomcoder/udroid/ui/DesktopPage.kt | 305 ++---------------- .../udroid/ui/X11DesktopHostView.kt | 291 +++++++++++++++++ .../udroid/ui/DesktopInputPaletteTest.kt | 23 +- 3 files changed, 315 insertions(+), 304 deletions(-) create mode 100644 app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt diff --git a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt index 83b6562..439f1a8 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt @@ -1,10 +1,7 @@ package org.randomcoder.udroid.ui import androidx.activity.compose.BackHandler -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,23 +9,16 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.icons.rounded.Keyboard -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.Mouse import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -36,30 +26,18 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import kotlin.math.roundToInt -import com.termux.x11.input.InputStub import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import org.randomcoder.udroid.runtime.RuntimeSupervisorService @@ -80,21 +58,12 @@ fun DesktopPage( mutableStateOf(!settings.startControlsCollapsed) } var inputPaletteVisible by rememberSaveable { mutableStateOf(false) } - var inputPaletteLocked by rememberSaveable { mutableStateOf(false) } - var heldMouseButton by remember { mutableStateOf(null) } - var inputPaletteX by rememberSaveable { mutableIntStateOf(UNSET_PALETTE_POSITION) } - var inputPaletteY by rememberSaveable { mutableIntStateOf(UNSET_PALETTE_POSITION) } var showSettings by remember { mutableStateOf(false) } var displayView by remember { mutableStateOf(null) } - val releaseHeldMouseButton = { - heldMouseButton?.let { displayView?.setMouseButton(it, false) } - heldMouseButton = null - } BackHandler { if (showSettings) { showSettings = false } else if (inputPaletteVisible) { - releaseHeldMouseButton() inputPaletteVisible = false } else { onExit() @@ -136,6 +105,11 @@ fun DesktopPage( } } + val palettePrimary = MaterialTheme.colorScheme.primaryContainer.toArgb() + val paletteSurface = MaterialTheme.colorScheme.surfaceContainerHigh.toArgb() + val paletteText = MaterialTheme.colorScheme.onSurface.toArgb() + val paletteOutline = MaterialTheme.colorScheme.outline.toArgb() + Surface( modifier = Modifier.fillMaxSize(), color = Color.Black, @@ -145,15 +119,9 @@ fun DesktopPage( DesktopControlBar( status = status, inputPaletteVisible = inputPaletteVisible, - onExit = { - releaseHeldMouseButton() - onExit() - }, + onExit = onExit, onKeyboard = { displayView?.showKeyboard() }, - onInputPalette = { - if (inputPaletteVisible) releaseHeldMouseButton() - inputPaletteVisible = !inputPaletteVisible - }, + onInputPalette = { inputPaletteVisible = !inputPaletteVisible }, onSettings = { showSettings = true }, onCollapse = { controlsExpanded = false }, ) @@ -171,34 +139,23 @@ fun DesktopPage( ) { AndroidView( factory = { context -> - X11DisplayView(context).also { - it.applySettings(settings) - displayView = it + X11DesktopHostView(context).also { + it.displayView.applySettings(settings) + displayView = it.displayView } }, - update = { it.applySettings(settings) }, + update = { + it.displayView.applySettings(settings) + it.setPaletteVisible(inputPaletteVisible) + it.setPaletteColors( + primaryContainer = palettePrimary, + surfaceContainer = paletteSurface, + onSurface = paletteText, + outline = paletteOutline, + ) + }, modifier = Modifier.fillMaxSize(), ) - if (inputPaletteVisible) { - DesktopInputPalette( - locked = inputPaletteLocked, - heldMouseButton = heldMouseButton, - positionX = inputPaletteX, - positionY = inputPaletteY, - onLockedChange = { inputPaletteLocked = it }, - onPositionChange = { x, y -> - inputPaletteX = x - inputPaletteY = y - }, - onMouseButton = { button -> - val next = nextHeldMouseButton(heldMouseButton, button) - heldMouseButton?.let { displayView?.setMouseButton(it, false) } - next?.let { displayView?.setMouseButton(it, true) } - heldMouseButton = next - }, - onScroll = { displayView?.scrollMouse(it) }, - ) - } } } } @@ -296,226 +253,6 @@ private fun DesktopControlBar( } } -@Composable -private fun DesktopInputPalette( - locked: Boolean, - heldMouseButton: Int?, - positionX: Int, - positionY: Int, - onLockedChange: (Boolean) -> Unit, - onPositionChange: (Int, Int) -> Unit, - onMouseButton: (Int) -> Unit, - onScroll: (Float) -> Unit, -) { - var containerSize by remember { mutableStateOf(IntSize.Zero) } - var paletteSize by remember { mutableStateOf(IntSize.Zero) } - val margin = 12.dp - val marginPixels = with(androidx.compose.ui.platform.LocalDensity.current) { margin.roundToPx() } - val latestPositionX = rememberUpdatedState(positionX) - val latestPositionY = rememberUpdatedState(positionY) - - LaunchedEffect(containerSize, paletteSize) { - if (containerSize == IntSize.Zero || paletteSize == IntSize.Zero) return@LaunchedEffect - val maxX = (containerSize.width - paletteSize.width).coerceAtLeast(0) - val maxY = (containerSize.height - paletteSize.height).coerceAtLeast(0) - val clampedX = - if (positionX == UNSET_PALETTE_POSITION) { - (maxX - marginPixels).coerceAtLeast(0) - } else { - clampInputPaletteCoordinate(positionX, maxX) - } - val clampedY = - if (positionY == UNSET_PALETTE_POSITION) { - marginPixels.coerceAtMost(maxY) - } else { - clampInputPaletteCoordinate(positionY, maxY) - } - if (clampedX != positionX || clampedY != positionY) { - onPositionChange(clampedX, clampedY) - } - } - - Box( - modifier = - Modifier - .fillMaxSize() - .onSizeChanged { containerSize = it }, - ) { - Surface( - modifier = - Modifier - .offset { - IntOffset( - positionX.coerceAtLeast(0), - positionY.coerceAtLeast(0), - ) - }.onSizeChanged { paletteSize = it }, - shape = RoundedCornerShape(18.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 6.dp, - shadowElevation = 3.dp, - ) { - Column(modifier = Modifier.padding(8.dp)) { - Row( - modifier = - Modifier - .width(184.dp) - .height(36.dp) - .then( - if (locked) { - Modifier - } else { - Modifier.pointerInput(containerSize, paletteSize) { - var dragX = 0f - var dragY = 0f - detectDragGestures( - onDragStart = { - dragX = latestPositionX.value.toFloat() - dragY = latestPositionY.value.toFloat() - }, - ) { change, dragAmount -> - change.consume() - val maxX = - (containerSize.width - paletteSize.width) - .coerceAtLeast(0) - val maxY = - (containerSize.height - paletteSize.height) - .coerceAtLeast(0) - dragX = - accumulateInputPaletteDrag(dragX, dragAmount.x, maxX) - dragY = - accumulateInputPaletteDrag(dragY, dragAmount.y, maxY) - onPositionChange(dragX.roundToInt(), dragY.roundToInt()) - } - } - }, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Rounded.DragHandle, - contentDescription = if (locked) null else "Move mouse controls", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Mouse", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(start = 4.dp), - ) - Spacer(modifier = Modifier.weight(1f)) - IconButton( - onClick = { onLockedChange(!locked) }, - modifier = Modifier.size(36.dp), - ) { - Icon( - imageVector = - if (locked) Icons.Rounded.Lock else Icons.Rounded.LockOpen, - contentDescription = - if (locked) { - "Unlock mouse controls" - } else { - "Lock mouse controls" - }, - ) - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - MouseButton( - label = "L", - description = "Hold left mouse button", - selected = heldMouseButton == InputStub.BUTTON_LEFT, - onClick = { onMouseButton(InputStub.BUTTON_LEFT) }, - ) - MouseButton( - label = "M", - description = "Hold middle mouse button", - selected = heldMouseButton == InputStub.BUTTON_MIDDLE, - onClick = { onMouseButton(InputStub.BUTTON_MIDDLE) }, - ) - MouseButton( - label = "R", - description = "Hold right mouse button", - selected = heldMouseButton == InputStub.BUTTON_RIGHT, - onClick = { onMouseButton(InputStub.BUTTON_RIGHT) }, - ) - Spacer(modifier = Modifier.width(4.dp)) - FilledTonalIconButton( - onClick = { onScroll(MOUSE_SCROLL_STEP) }, - modifier = Modifier.size(40.dp), - ) { - Icon( - imageVector = Icons.Rounded.KeyboardArrowUp, - contentDescription = "Scroll up", - ) - } - FilledTonalIconButton( - onClick = { onScroll(-MOUSE_SCROLL_STEP) }, - modifier = Modifier.size(40.dp), - ) { - Icon( - imageVector = Icons.Rounded.KeyboardArrowDown, - contentDescription = "Scroll down", - ) - } - } - } - } - } -} - -@Composable -private fun MouseButton( - label: String, - description: String, - selected: Boolean, - onClick: () -> Unit, -) { - Surface( - modifier = - Modifier - .size(40.dp) - .clickable( - role = Role.Button, - onClickLabel = if (selected) "Release" else "Hold", - onClick = onClick, - ).semantics { - role = Role.Button - contentDescription = description - }, - shape = RoundedCornerShape(20.dp), - color = - if (selected) { - MaterialTheme.colorScheme.primaryContainer - } else { - Color.Transparent - }, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - ) { - Box(contentAlignment = Alignment.Center) { - Text(text = label, style = MaterialTheme.typography.labelLarge) - } - } -} - -internal fun clampInputPaletteCoordinate( - value: Int, - maximum: Int, -): Int = value.coerceIn(0, maximum.coerceAtLeast(0)) - -internal fun nextHeldMouseButton( - current: Int?, - tapped: Int, -): Int? = if (current == tapped) null else tapped - -internal fun accumulateInputPaletteDrag( - current: Float, - delta: Float, - maximum: Int, -): Float = (current + delta).coerceIn(0f, maximum.coerceAtLeast(0).toFloat()) - -private const val UNSET_PALETTE_POSITION = Int.MIN_VALUE -private const val MOUSE_SCROLL_STEP = 120f - @Composable private fun CollapsedDesktopControlBar( attached: Boolean, diff --git a/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt b/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt new file mode 100644 index 0000000..313c7fd --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt @@ -0,0 +1,291 @@ +package org.randomcoder.udroid.ui + +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.termux.x11.input.InputStub +import org.randomcoder.udroid.x11.X11DisplayView +import kotlin.math.roundToInt + +internal class X11DesktopHostView(context: Context) : FrameLayout(context) { + val displayView = X11DisplayView(context) + + private val density = resources.displayMetrics.density + private val palette = LinearLayout(context) + private val mouseButtons = mutableMapOf() + private val scrollButtons = mutableListOf() + private val paletteTextViews = mutableListOf() + private var paletteLocked = false + private var latchedButton = InputStub.BUTTON_UNDEFINED + private var momentaryButton = InputStub.BUTTON_UNDEFINED + private var primaryContainer = Color.DKGRAY + private var surfaceContainer = Color.rgb(35, 35, 35) + private var onSurface = Color.WHITE + private var outline = Color.GRAY + + init { + isMotionEventSplittingEnabled = true + addView( + displayView, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT), + ) + buildPalette() + addView( + palette, + LayoutParams(dp(160), LayoutParams.WRAP_CONTENT).apply { + gravity = Gravity.TOP or Gravity.END + setMargins(dp(12), dp(12), dp(12), dp(12)) + }, + ) + palette.visibility = View.GONE + } + + fun setPaletteVisible(visible: Boolean) { + if (!visible) releaseMouseButtons() + palette.visibility = if (visible) View.VISIBLE else View.GONE + } + + fun setPaletteColors( + primaryContainer: Int, + surfaceContainer: Int, + onSurface: Int, + outline: Int, + ) { + this.primaryContainer = primaryContainer + this.surfaceContainer = surfaceContainer + this.onSurface = onSurface + this.outline = outline + palette.background = roundedBackground(surfaceContainer, outline, 30) + paletteTextViews.forEach { it.setTextColor(onSurface) } + mouseButtons.forEach { (button, view) -> updateMouseButton(view, button) } + scrollButtons.forEach { it.background = mouseButtonBackground(true) } + } + + private fun buildPalette() { + palette.orientation = LinearLayout.VERTICAL + palette.setPadding(dp(8), dp(8), dp(8), dp(8)) + palette.background = roundedBackground(surfaceContainer, outline, 30) + + val header = + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + val title = textButton("☰ Mouse", "Move mouse controls").apply { + gravity = Gravity.CENTER_VERTICAL + setTypeface(typeface, Typeface.BOLD) + isClickable = false + } + val lock = textButton("Lock", "Lock mouse controls") + header.addView(title, LinearLayout.LayoutParams(0, dp(36), 1f)) + header.addView(lock, LinearLayout.LayoutParams(dp(52), dp(36))) + palette.addView(header, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(36))) + + lock.setOnClickListener { + paletteLocked = !paletteLocked + lock.text = if (paletteLocked) "Move" else "Lock" + lock.contentDescription = + if (paletteLocked) "Unlock mouse controls" else "Lock mouse controls" + } + installPaletteDrag(header) + + val mouseBody = + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + + fun addMouseButton(button: Int, label: String, width: Int, height: Int) { + val view = textButton(label, "Hold $label mouse button") + mouseButtons[button] = view + installMouseButton(view, button) + mouseBody.addView(view, LinearLayout.LayoutParams(dp(width), dp(height))) + } + + addMouseButton(InputStub.BUTTON_LEFT, "L", 48, 108) + mouseBody.addView(View(context), LinearLayout.LayoutParams(dp(4), 1)) + val wheel = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } + wheel.addView(scrollButton("↑", 120f), LinearLayout.LayoutParams(dp(36), dp(36))) + val middle = textButton("M", "Hold middle mouse button") + mouseButtons[InputStub.BUTTON_MIDDLE] = middle + installMouseButton(middle, InputStub.BUTTON_MIDDLE) + wheel.addView(middle, LinearLayout.LayoutParams(dp(36), dp(36))) + wheel.addView(scrollButton("↓", -120f), LinearLayout.LayoutParams(dp(36), dp(36))) + mouseBody.addView(wheel, LinearLayout.LayoutParams(dp(36), dp(108))) + mouseBody.addView(View(context), LinearLayout.LayoutParams(dp(4), 1)) + addMouseButton(InputStub.BUTTON_RIGHT, "R", 48, 108) + palette.addView(mouseBody) + } + + private fun installPaletteDrag(header: View) { + var startRawX = 0f + var startRawY = 0f + var startX = 0f + var startY = 0f + header.setOnTouchListener { _, event -> + if (paletteLocked) return@setOnTouchListener false + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + startRawX = event.rawX + startRawY = event.rawY + startX = palette.x + startY = palette.y + true + } + + MotionEvent.ACTION_MOVE -> { + palette.x = + (startX + event.rawX - startRawX) + .coerceIn(0f, (width - palette.width).coerceAtLeast(0).toFloat()) + palette.y = + (startY + event.rawY - startRawY) + .coerceIn(0f, (height - palette.height).coerceAtLeast(0).toFloat()) + true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> true + else -> false + } + } + } + + private fun installMouseButton(view: TextView, button: Int) { + var momentaryPress = false + val beginMomentaryPress = + Runnable { + if (view.isPressed && latchedButton != button) { + releaseMouseButtons() + momentaryButton = button + momentaryPress = true + displayView.setMouseButton(button, true) + updateMouseButtons() + } + } + view.setOnClickListener { toggleMouseButton(button) } + view.setOnTouchListener { target, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + target.isPressed = true + momentaryPress = false + target.postDelayed( + beginMomentaryPress, + ViewConfiguration.getTapTimeout().toLong(), + ) + true + } + + MotionEvent.ACTION_UP -> { + target.removeCallbacks(beginMomentaryPress) + target.isPressed = false + if (momentaryPress) { + displayView.setMouseButton(button, false) + momentaryButton = InputStub.BUTTON_UNDEFINED + updateMouseButtons() + } else { + target.performClick() + } + momentaryPress = false + true + } + + MotionEvent.ACTION_CANCEL -> { + target.removeCallbacks(beginMomentaryPress) + target.isPressed = false + if (momentaryPress) { + displayView.setMouseButton(button, false) + momentaryButton = InputStub.BUTTON_UNDEFINED + updateMouseButtons() + } + momentaryPress = false + true + } + + else -> true + } + } + } + + private fun toggleMouseButton(button: Int) { + val next = nextLatchedMouseButton(latchedButton, button) + releaseMouseButtons() + latchedButton = next + if (next != InputStub.BUTTON_UNDEFINED) displayView.setMouseButton(next, true) + updateMouseButtons() + } + + private fun releaseMouseButtons() { + if (latchedButton != InputStub.BUTTON_UNDEFINED) { + displayView.setMouseButton(latchedButton, false) + } + if (momentaryButton != InputStub.BUTTON_UNDEFINED) { + displayView.setMouseButton(momentaryButton, false) + } + latchedButton = InputStub.BUTTON_UNDEFINED + momentaryButton = InputStub.BUTTON_UNDEFINED + updateMouseButtons() + } + + private fun updateMouseButtons() { + mouseButtons.forEach { (button, view) -> updateMouseButton(view, button) } + } + + private fun updateMouseButton(view: TextView, button: Int) { + val active = latchedButton == button || momentaryButton == button + view.background = mouseButtonBackground(active) + } + + private fun scrollButton(label: String, amount: Float) = + textButton(label, if (amount > 0) "Scroll up" else "Scroll down").apply { + setOnClickListener { displayView.scrollMouse(amount) } + background = mouseButtonBackground(true) + scrollButtons += this + } + + private fun textButton(label: String, description: String) = + TextView(context) + .apply { + text = label + contentDescription = description + gravity = Gravity.CENTER + setTextColor(onSurface) + textSize = 14f + isClickable = true + isFocusable = true + }.also { paletteTextViews += it } + + private fun mouseButtonBackground(active: Boolean): StateListDrawable = + StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_pressed), roundedBackground(primaryContainer, outline, 20)) + addState( + intArrayOf(), + roundedBackground(if (active) primaryContainer else Color.TRANSPARENT, outline, 20), + ) + } + + private fun roundedBackground(fill: Int, stroke: Int, radiusDp: Int) = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(radiusDp).toFloat() + setColor(fill) + setStroke(dp(1), stroke) + } + + private fun dp(value: Int): Int = (value * density).roundToInt() + + override fun onDetachedFromWindow() { + releaseMouseButtons() + super.onDetachedFromWindow() + } +} + +internal fun nextLatchedMouseButton(current: Int, tapped: Int): Int = + if (current == tapped) InputStub.BUTTON_UNDEFINED else tapped diff --git a/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt index 3260944..334b245 100644 --- a/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt @@ -4,27 +4,10 @@ import org.junit.Assert.assertEquals import org.junit.Test class DesktopInputPaletteTest { - @Test - fun positionIsClampedInsideTheDesktop() { - assertEquals(0, clampInputPaletteCoordinate(-20, 300)) - assertEquals(180, clampInputPaletteCoordinate(180, 300)) - assertEquals(300, clampInputPaletteCoordinate(420, 300)) - assertEquals(0, clampInputPaletteCoordinate(20, -1)) - } - @Test fun mouseButtonTapHoldsSwitchesAndReleases() { - assertEquals(1, nextHeldMouseButton(null, 1)) - assertEquals(3, nextHeldMouseButton(1, 3)) - assertEquals(null, nextHeldMouseButton(3, 3)) - } - - @Test - fun dragAccumulatesEveryMotionDelta() { - val firstMove = accumulateInputPaletteDrag(100f, 8f, 300) - val secondMove = accumulateInputPaletteDrag(firstMove, 7f, 300) - - assertEquals(115f, secondMove) - assertEquals(300f, accumulateInputPaletteDrag(secondMove, 500f, 300)) + assertEquals(1, nextLatchedMouseButton(0, 1)) + assertEquals(3, nextLatchedMouseButton(1, 3)) + assertEquals(0, nextLatchedMouseButton(3, 3)) } } From fe09294e895fa4bd1f793e33f29c3476b66e7dcd Mon Sep 17 00:00:00 2001 From: saicharankandukuri Date: Fri, 18 Sep 2026 15:06:01 +0530 Subject: [PATCH 3/3] feat(runtime): warn about Android process limits --- .../udroid/runtime/CapabilityProbe.kt | 124 +++++++++++++++++ .../udroid/runtime/RuntimeModels.kt | 3 + .../org/randomcoder/udroid/ui/AppShell.kt | 53 +++++++- .../randomcoder/udroid/ui/LinuxSystemPage.kt | 32 +++++ .../udroid/runtime/CapabilityProbeTest.kt | 125 ++++++++++++++++++ 5 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/org/randomcoder/udroid/runtime/CapabilityProbeTest.kt diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/CapabilityProbe.kt b/app/src/main/java/org/randomcoder/udroid/runtime/CapabilityProbe.kt index b364fb2..babf5a2 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/CapabilityProbe.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/CapabilityProbe.kt @@ -4,10 +4,16 @@ import android.content.Context import android.hardware.HardwareBuffer import android.os.Build import android.os.storage.StorageManager +import android.provider.Settings import android.system.Os import android.system.OsConstants import java.io.File +internal data class ProbeRead( + val succeeded: Boolean, + val value: String? = null, +) + object CapabilityProbe { fun run(context: Context): List { val results = mutableListOf() @@ -33,6 +39,8 @@ object CapabilityProbe { required = false, ) + results += phantomProcessMonitorResult(context) + val dmaHeapDirectory = File("/dev/dma_heap") val heaps = dmaHeapDirectory.list() @@ -107,6 +115,37 @@ object CapabilityProbe { Os.close(descriptor) }.isSuccess + private fun phantomProcessMonitorResult(context: Context): CapabilityResult { + if (Build.VERSION.SDK_INT < 31) { + return classifyPhantomProcessMonitor(Build.VERSION.SDK_INT, ProbeRead(false), ProbeRead(false)) + } + + val global = + runCatching { + Settings.Global.getString( + context.contentResolver, + PHANTOM_PROCESS_MONITOR_SETTING, + ) + }.fold( + onSuccess = { ProbeRead(succeeded = true, value = it?.trim()) }, + onFailure = { ProbeRead(succeeded = false) }, + ) + val property = + if (global.succeeded && global.value.isNullOrBlank()) { + readSystemProperty(PHANTOM_PROCESS_MONITOR_PROPERTY) + } else { + ProbeRead(succeeded = false) + } + return classifyPhantomProcessMonitor(Build.VERSION.SDK_INT, global, property) + } + + private fun readSystemProperty(name: String): ProbeRead = + runCatching { + val process = ProcessBuilder("/system/bin/getprop", name).start() + val value = process.inputStream.bufferedReader().use { it.readText().trim() } + if (process.waitFor() == 0) ProbeRead(succeeded = true, value = value) else ProbeRead(false) + }.getOrElse { ProbeRead(succeeded = false) } + private fun result( name: String, passed: Boolean, @@ -123,4 +162,89 @@ object CapabilityProbe { val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) return "%.1f GiB".format(gib) } + + private const val PHANTOM_PROCESS_MONITOR_SETTING = + "settings_enable_monitor_phantom_procs" + private const val PHANTOM_PROCESS_MONITOR_PROPERTY = + "persist.sys.fflag.override.settings_enable_monitor_phantom_procs" } + +internal fun classifyPhantomProcessMonitor( + sdkInt: Int, + global: ProbeRead, + property: ProbeRead, +): CapabilityResult { + if (sdkInt < 31) { + return CapabilityResult( + name = "Child process restrictions", + status = CapabilityStatus.PASS, + detail = "Not used by this Android version", + required = false, + ) + } + + if (!global.succeeded) return unknownPhantomProcessMonitorResult(sdkInt) + val selected = if (global.value.isNullOrBlank()) property else global + if (!selected.succeeded) return unknownPhantomProcessMonitorResult(sdkInt) + + val value = selected.value.orEmpty() + val guidance = phantomProcessMonitorGuidance(sdkInt) + if (value.isBlank()) { + return CapabilityResult( + name = "Child process restrictions", + status = CapabilityStatus.WARNING, + detail = + "No override is set; Android's default is enabled on standard builds and may " + + "stop Linux child processes. $guidance", + required = false, + showDeveloperOptionsAction = sdkInt >= 34, + ) + } + + return when (value.lowercase()) { + "false" -> + CapabilityResult( + name = "Child process restrictions", + status = CapabilityStatus.PASS, + detail = "Disabled; Linux child processes are not restricted by this monitor", + required = false, + ) + "true" -> + CapabilityResult( + name = "Child process restrictions", + status = CapabilityStatus.WARNING, + detail = activePhantomProcessMonitorDetail(sdkInt), + required = false, + showDeveloperOptionsAction = sdkInt >= 34, + linuxProcessRestrictionActive = true, + ) + else -> unknownPhantomProcessMonitorResult(sdkInt) + } +} + +private fun unknownPhantomProcessMonitorResult(sdkInt: Int) = + CapabilityResult( + name = "Child process restrictions", + status = CapabilityStatus.WARNING, + detail = "State unknown. ${phantomProcessMonitorGuidance(sdkInt)}", + required = false, + showDeveloperOptionsAction = sdkInt >= 34, + ) + +private fun phantomProcessMonitorGuidance(sdkInt: Int): String = + if (sdkInt >= 34) { + "Enable Developer options > Disable child process restrictions. uDroid cannot change it." + } else { + "This Android version has no built-in toggle and uDroid cannot change it. Run: " + + "adb shell settings put global settings_enable_monitor_phantom_procs false" + } + +private fun activePhantomProcessMonitorDetail(sdkInt: Int): String = + if (sdkInt >= 34) { + "Android can stop desktops and larger Linux apps when they start many background " + + "processes. In Developer options, turn on “Disable child process restrictions.”" + } else { + "Android can stop desktops and larger Linux apps when they start many background " + + "processes. This Android version has no settings toggle. Use ADB: adb shell " + + "settings put global settings_enable_monitor_phantom_procs false" + } diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeModels.kt b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeModels.kt index 5010159..1a71b6f 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeModels.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeModels.kt @@ -23,6 +23,7 @@ data class RuntimeSnapshot( enum class CapabilityStatus { PASS, FAIL, + WARNING, INFO, } @@ -31,4 +32,6 @@ data class CapabilityResult( val status: CapabilityStatus, val detail: String, val required: Boolean, + val showDeveloperOptionsAction: Boolean = false, + val linuxProcessRestrictionActive: Boolean = false, ) diff --git a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt index 34a904e..fed48fd 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/AppShell.kt @@ -2,9 +2,13 @@ package org.randomcoder.udroid.ui import android.content.ClipData import android.content.ClipboardManager +import android.content.Intent import android.os.Build +import android.provider.Settings import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -834,6 +838,10 @@ private fun ManagementPane( scanMessage = desktopScanMessage, audioConfiguration = audioConfiguration, audioConfigurationMessage = audioConfigurationMessage, + childProcessRestriction = + capabilities.firstOrNull { + it.linuxProcessRestrictionActive + }, resetAvailable = selectedRootfs.name in resettableRootfsNames || selectedDistro != null, @@ -863,6 +871,7 @@ private fun ManagementPane( onGraphicsProfileChanged = onGraphicsProfileChanged, onAudioOutputChanged = onAudioOutputChanged, onMicrophoneChanged = onMicrophoneChanged, + onRefreshCapabilities = onRefresh, onStartDesktop = onStartDesktop, onStopTerminal = onStop, onStopDesktop = onStopDesktop, @@ -1241,6 +1250,7 @@ private fun DevicePage( capabilities: List, onRefresh: () -> Unit, ) { + val openDeveloperOptions = rememberDeveloperOptionsAction(onRefresh) LazyColumn( modifier = Modifier @@ -1265,14 +1275,20 @@ private fun DevicePage( ) } items(capabilities) { capability -> - CapabilityRow(capability) + CapabilityRow( + capability = capability, + onOpenDeveloperOptions = openDeveloperOptions, + ) } item { Spacer(Modifier.height(16.dp)) } } } @Composable -private fun CapabilityRow(capability: CapabilityResult) { +private fun CapabilityRow( + capability: CapabilityResult, + onOpenDeveloperOptions: () -> Unit, +) { val (icon, tint, label) = when (capability.status) { CapabilityStatus.PASS -> @@ -1283,6 +1299,8 @@ private fun CapabilityRow(capability: CapabilityResult) { if (capability.required) MaterialTheme.colorScheme.error else UdroidWarning, if (capability.required) "Required" else "Unavailable", ) + CapabilityStatus.WARNING -> + Triple(Icons.Rounded.ErrorOutline, UdroidWarning, "Review") CapabilityStatus.INFO -> Triple(Icons.Rounded.Info, MaterialTheme.colorScheme.tertiary, "Detected") } @@ -1300,7 +1318,16 @@ private fun CapabilityRow(capability: CapabilityResult) { Text(capability.name, style = MaterialTheme.typography.titleMedium) }, supportingContent = { - Text(capability.detail, style = MaterialTheme.typography.bodySmall) + Column { + Text(capability.detail, style = MaterialTheme.typography.bodySmall) + if (capability.showDeveloperOptionsAction) { + TextButton( + onClick = onOpenDeveloperOptions, + ) { + Text("Open Developer options") + } + } + } }, trailingContent = { Text( @@ -1312,6 +1339,26 @@ private fun CapabilityRow(capability: CapabilityResult) { ) } +@Composable +internal fun rememberDeveloperOptionsAction(onReturn: () -> Unit): () -> Unit { + val context = LocalContext.current + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + onReturn() + } + return { + runCatching { + launcher.launch(Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)) + }.onFailure { + Toast.makeText( + context, + "Developer options are unavailable on this device", + Toast.LENGTH_LONG, + ).show() + } + } +} + @Composable private fun AboutPage( journalLines: List, diff --git a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt index 7355c53..b207ead 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/LinuxSystemPage.kt @@ -61,6 +61,7 @@ import org.randomcoder.udroid.audio.AudioConfiguration import org.randomcoder.udroid.catalog.DistroVariant import org.randomcoder.udroid.catalog.LinuxDistribution import org.randomcoder.udroid.runtime.DesktopCompositorSupport +import org.randomcoder.udroid.runtime.CapabilityResult import org.randomcoder.udroid.runtime.DesktopConfiguration import org.randomcoder.udroid.runtime.DesktopEnvironment import org.randomcoder.udroid.runtime.DesktopGraphicsProfile @@ -87,6 +88,7 @@ fun LinuxSystemPage( scanMessage: String?, audioConfiguration: AudioConfiguration, audioConfigurationMessage: String?, + childProcessRestriction: CapabilityResult?, resetAvailable: Boolean, maintenanceInProgress: Boolean, maintenanceMessage: String?, @@ -100,6 +102,7 @@ fun LinuxSystemPage( onGraphicsProfileChanged: (DesktopGraphicsProfile) -> Unit, onAudioOutputChanged: (Boolean) -> Unit, onMicrophoneChanged: (Boolean) -> Unit, + onRefreshCapabilities: () -> Unit, onStartDesktop: () -> Unit, onStopTerminal: () -> Unit, onStopDesktop: () -> Unit, @@ -110,6 +113,7 @@ fun LinuxSystemPage( ) { BackHandler(onBack = onBack) val context = androidx.compose.ui.platform.LocalContext.current + val openDeveloperOptions = rememberDeveloperOptionsAction(onRefreshCapabilities) val mountProfileStore = remember(context) { ProotMountProfileStore(context) } var confirmation by remember(rootfs.name) { mutableStateOf(null) @@ -242,6 +246,34 @@ fun LinuxSystemPage( } } + childProcessRestriction?.let { restriction -> + item(key = "child-process-restriction") { + Surface( + color = UdroidWarningSurface, + shape = MaterialTheme.shapes.large, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + "Android may close Linux apps", + color = UdroidWarning, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(6.dp)) + Text( + restriction.detail, + style = MaterialTheme.typography.bodyMedium, + ) + if (restriction.showDeveloperOptionsAction) { + TextButton(onClick = openDeveloperOptions) { + Text("Open Developer options") + } + } + } + } + } + } + item(key = "audio-label") { UdroidSectionLabel( text = "Audio", diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/CapabilityProbeTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/CapabilityProbeTest.kt new file mode 100644 index 0000000..64e3f57 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/CapabilityProbeTest.kt @@ -0,0 +1,125 @@ +package org.randomcoder.udroid.runtime + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CapabilityProbeTest { + @Test + fun `classifies phantom process monitor precedence and failures`() { + data class Case( + val name: String, + val sdk: Int = 31, + val global: ProbeRead, + val property: ProbeRead, + val expected: CapabilityStatus, + val expectedAction: Boolean = false, + val expectedActive: Boolean = false, + ) + + val cases = + listOf( + Case( + "global false overrides property", + global = read("false"), + property = read("true"), + expected = CapabilityStatus.PASS, + ), + Case( + "global true overrides property", + global = read("true"), + property = read("false"), + expected = CapabilityStatus.WARNING, + expectedActive = true, + ), + Case( + "blank global falls through", + global = read(""), + property = read("false"), + expected = CapabilityStatus.PASS, + ), + Case( + "both blank use default", + global = read(""), + property = read(""), + expected = CapabilityStatus.WARNING, + ), + Case( + "blank global falls through to enabled property", + global = read(""), + property = read("true"), + expected = CapabilityStatus.WARNING, + expectedActive = true, + ), + Case( + "failed global is unknown", + global = ProbeRead(false), + property = read("false"), + expected = CapabilityStatus.WARNING, + ), + Case( + "failed property is unknown", + global = read(""), + property = ProbeRead(false), + expected = CapabilityStatus.WARNING, + ), + Case( + "invalid global is unknown", + global = read("sometimes"), + property = read("false"), + expected = CapabilityStatus.WARNING, + ), + Case( + "Android 30 does not use monitor", + sdk = 30, + global = ProbeRead(false), + property = ProbeRead(false), + expected = CapabilityStatus.PASS, + ), + Case( + "API 33 enabled has no settings action", + sdk = 33, + global = read("true"), + property = read(""), + expected = CapabilityStatus.WARNING, + expectedActive = true, + ), + Case( + "API 33 unknown has no settings action", + sdk = 33, + global = ProbeRead(false), + property = read(""), + expected = CapabilityStatus.WARNING, + ), + Case( + "API 34 enabled has settings action", + sdk = 34, + global = read("true"), + property = read(""), + expected = CapabilityStatus.WARNING, + expectedAction = true, + expectedActive = true, + ), + Case( + "API 34 unknown has settings action", + sdk = 34, + global = ProbeRead(false), + property = read(""), + expected = CapabilityStatus.WARNING, + expectedAction = true, + ), + ) + + cases.forEach { case -> + val result = classifyPhantomProcessMonitor(case.sdk, case.global, case.property) + assertEquals( + case.name, + case.expected, + result.status, + ) + assertEquals(case.name, case.expectedAction, result.showDeveloperOptionsAction) + assertEquals(case.name, case.expectedActive, result.linuxProcessRestrictionActive) + } + } + + private fun read(value: String) = ProbeRead(succeeded = true, value = value) +}