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/DesktopPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt index 4897cd1..439f1a8 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt @@ -2,18 +2,22 @@ package org.randomcoder.udroid.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.Mouse import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -25,10 +29,12 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -51,15 +57,18 @@ fun DesktopPage( var controlsExpanded by remember { mutableStateOf(!settings.startControlsCollapsed) } + var inputPaletteVisible by rememberSaveable { mutableStateOf(false) } var showSettings by remember { mutableStateOf(false) } + var displayView by remember { mutableStateOf(null) } BackHandler { if (showSettings) { showSettings = false + } else if (inputPaletteVisible) { + 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) @@ -96,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, @@ -104,8 +118,10 @@ fun DesktopPage( if (controlsExpanded) { DesktopControlBar( status = status, + inputPaletteVisible = inputPaletteVisible, onExit = onExit, onKeyboard = { displayView?.showKeyboard() }, + onInputPalette = { inputPaletteVisible = !inputPaletteVisible }, onSettings = { showSettings = true }, onCollapse = { controlsExpanded = false }, ) @@ -115,19 +131,32 @@ 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 -> + X11DesktopHostView(context).also { + it.displayView.applySettings(settings) + displayView = it.displayView + } + }, + update = { + it.displayView.applySettings(settings) + it.setPaletteVisible(inputPaletteVisible) + it.setPaletteColors( + primaryContainer = palettePrimary, + surfaceContainer = paletteSurface, + onSurface = paletteText, + outline = paletteOutline, + ) + }, + modifier = Modifier.fillMaxSize(), + ) + } } } @@ -143,8 +172,10 @@ fun DesktopPage( @Composable private fun DesktopControlBar( status: String, + inputPaletteVisible: Boolean, onExit: () -> Unit, onKeyboard: () -> Unit, + onInputPalette: () -> Unit, onSettings: () -> Unit, onCollapse: () -> Unit, ) { @@ -188,6 +219,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, 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/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/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/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) +} 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..334b245 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/ui/DesktopInputPaletteTest.kt @@ -0,0 +1,13 @@ +package org.randomcoder.udroid.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DesktopInputPaletteTest { + @Test + fun mouseButtonTapHoldsSwitchesAndReleases() { + assertEquals(1, nextLatchedMouseButton(0, 1)) + assertEquals(3, nextLatchedMouseButton(1, 3)) + assertEquals(0, nextLatchedMouseButton(3, 3)) + } +} 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);