From f333829803bfc6adb07fbe2febb6decea059179f Mon Sep 17 00:00:00 2001 From: izzy2lost Date: Sun, 8 Mar 2026 21:34:00 -0400 Subject: [PATCH] Added frontend support --- android/app/src/main/AndroidManifest.xml | 13 + .../izzy2lost/x1box/ControllerInputBridge.kt | 27 ++- .../izzy2lost/x1box/FrontendLaunchHelper.kt | 227 ++++++++++++++++++ .../com/izzy2lost/x1box/LauncherActivity.kt | 65 +++++ .../java/com/izzy2lost/x1box/MainActivity.kt | 5 + .../com/izzy2lost/x1box/OnScreenController.kt | 64 ++++- android/app/src/main/res/values/strings.xml | 2 + 7 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ede9022327..2215c48ddc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,6 +18,19 @@ + + + + + + + + + + + + + - SDLControllerManager.onNativeJoy(VIRTUAL_DEVICE_ID, AXIS_LEFT_TRIGGER, 1.0f) + OnScreenController.Button.LEFT_TRIGGER, OnScreenController.Button.RIGHT_TRIGGER -> - SDLControllerManager.onNativeJoy(VIRTUAL_DEVICE_ID, AXIS_RIGHT_TRIGGER, 1.0f) + setTriggerState(button, pressed = true) else -> { val keyCode = getKeyCodeForButton(button) SDLControllerManager.onNativePadDown(VIRTUAL_DEVICE_ID, keyCode) @@ -41,10 +40,9 @@ class ControllerInputBridge : OnScreenController.ControllerListener { override fun onButtonReleased(button: OnScreenController.Button) { try { when (button) { - OnScreenController.Button.LEFT_TRIGGER -> - SDLControllerManager.onNativeJoy(VIRTUAL_DEVICE_ID, AXIS_LEFT_TRIGGER, 0.0f) + OnScreenController.Button.LEFT_TRIGGER, OnScreenController.Button.RIGHT_TRIGGER -> - SDLControllerManager.onNativeJoy(VIRTUAL_DEVICE_ID, AXIS_RIGHT_TRIGGER, 0.0f) + setTriggerState(button, pressed = false) else -> { val keyCode = getKeyCodeForButton(button) SDLControllerManager.onNativePadUp(VIRTUAL_DEVICE_ID, keyCode) @@ -96,6 +94,23 @@ class ControllerInputBridge : OnScreenController.ControllerListener { } } + private fun setTriggerState(button: OnScreenController.Button, pressed: Boolean) { + val keyCode = getKeyCodeForButton(button) + val axis = when (button) { + OnScreenController.Button.LEFT_TRIGGER -> AXIS_LEFT_TRIGGER + OnScreenController.Button.RIGHT_TRIGGER -> AXIS_RIGHT_TRIGGER + else -> return + } + val axisValue = if (pressed) 1.0f else 0.0f + + if (pressed) { + SDLControllerManager.onNativePadDown(VIRTUAL_DEVICE_ID, keyCode) + } else { + SDLControllerManager.onNativePadUp(VIRTUAL_DEVICE_ID, keyCode) + } + SDLControllerManager.onNativeJoy(VIRTUAL_DEVICE_ID, axis, axisValue) + } + private fun getKeyCodeForButton(button: OnScreenController.Button): Int { return when (button) { OnScreenController.Button.A -> KeyEvent.KEYCODE_BUTTON_A diff --git a/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt b/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt new file mode 100644 index 0000000000..75c6a36fc9 --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/x1box/FrontendLaunchHelper.kt @@ -0,0 +1,227 @@ +package com.izzy2lost.x1box + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import java.io.File +import java.util.Locale + +object FrontendLaunchHelper { + data class LaunchTarget( + val dvdUri: Uri? = null, + val dvdPath: String? = null, + val source: String + ) + + private val stringExtraKeys = listOf( + "rom", + "ROM", + "path", + "PATH", + "file", + "FILE", + "filename", + "FILENAME", + "romPath", + "ROM_PATH", + "uri", + "URI", + ) + + fun resolve(context: Context, intent: Intent?, gamesFolderUri: Uri?): LaunchTarget? { + if (intent == null) { + return null + } + + for ((label, rawValue) in collectCandidates(intent)) { + val resolved = resolveCandidate(context, gamesFolderUri, rawValue, label) + if (resolved != null) { + return resolved + } + } + return null + } + + fun persistReadPermission(context: Context, intent: Intent?, uri: Uri) { + if (intent == null) { + return + } + val flags = intent.flags and + (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + if (flags == 0) { + return + } + try { + context.contentResolver.takePersistableUriPermission(uri, flags) + } catch (_: SecurityException) { + } catch (_: IllegalArgumentException) { + } + } + + private fun collectCandidates(intent: Intent): List> { + val candidates = ArrayList>() + + intent.data?.let { candidates += "intent.data" to it } + getExtraStream(intent)?.let { candidates += "Intent.EXTRA_STREAM" to it } + intent.clipData?.let { clipData -> + for (index in 0 until clipData.itemCount) { + clipData.getItemAt(index)?.uri?.let { uri -> + candidates += "clipData[$index]" to uri + } + } + } + + val extras = intent.extras + if (extras != null) { + for (key in stringExtraKeys) { + when (val value = extras.get(key)) { + is Uri -> candidates += "extra:$key" to value + is String -> candidates += "extra:$key" to value + is CharSequence -> candidates += "extra:$key" to value.toString() + } + } + } + + return candidates + } + + private fun resolveCandidate( + context: Context, + gamesFolderUri: Uri?, + rawValue: Any, + label: String + ): LaunchTarget? { + return when (rawValue) { + is Uri -> resolveUri(context, gamesFolderUri, rawValue, label) + is String -> resolveString(context, gamesFolderUri, rawValue, label) + else -> null + } + } + + private fun resolveUri( + context: Context, + gamesFolderUri: Uri?, + uri: Uri, + label: String + ): LaunchTarget? { + return when (uri.scheme?.lowercase(Locale.ROOT)) { + null, "" -> resolvePath(context, gamesFolderUri, uri.toString(), label) + "file" -> resolvePath(context, gamesFolderUri, uri.path, label) + else -> LaunchTarget(dvdUri = uri, source = label) + } + } + + private fun resolveString( + context: Context, + gamesFolderUri: Uri?, + value: String, + label: String + ): LaunchTarget? { + val trimmed = value.trim() + if (trimmed.isEmpty()) { + return null + } + + if (trimmed.startsWith("/")) { + return resolvePath(context, gamesFolderUri, trimmed, label) + } + + val parsed = runCatching { Uri.parse(trimmed) }.getOrNull() + if (parsed != null && !parsed.scheme.isNullOrBlank()) { + return resolveUri(context, gamesFolderUri, parsed, label) + } + + return resolvePath(context, gamesFolderUri, trimmed, label) + } + + private fun resolvePath( + context: Context, + gamesFolderUri: Uri?, + rawPath: String?, + label: String + ): LaunchTarget? { + val path = rawPath?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val directFile = File(path) + if (directFile.isFile && directFile.canRead()) { + return LaunchTarget(dvdPath = directFile.absolutePath, source = label) + } + + val treeMatch = resolvePathAgainstGamesFolder(context, gamesFolderUri, path) + if (treeMatch != null) { + return LaunchTarget(dvdUri = treeMatch, source = label) + } + + return null + } + + private fun resolvePathAgainstGamesFolder( + context: Context, + gamesFolderUri: Uri?, + rawPath: String + ): Uri? { + val treeUri = gamesFolderUri ?: return null + val treeRootPath = treeUriToFilesystemPath(treeUri) ?: return null + val normalizedTree = normalizeFilesystemPath(treeRootPath) + val normalizedFile = normalizeFilesystemPath(rawPath) + val relativePath = when { + normalizedFile == normalizedTree -> "" + normalizedFile.startsWith("$normalizedTree/") -> + normalizedFile.removePrefix("$normalizedTree/") + else -> return null + } + + var node = DocumentFile.fromTreeUri(context, treeUri) ?: return null + if (relativePath.isEmpty()) { + return node.takeIf { it.isFile }?.uri + } + + for (segment in relativePath.split('/')) { + if (segment.isEmpty()) { + continue + } + node = node.findFile(segment) ?: return null + } + return node.takeIf { it.isFile }?.uri + } + + private fun treeUriToFilesystemPath(treeUri: Uri): String? { + val documentId = runCatching { DocumentsContract.getTreeDocumentId(treeUri) }.getOrNull() + ?: return null + val volume = documentId.substringBefore(':', "") + val relative = documentId.substringAfter(':', "") + return when { + volume.equals("primary", ignoreCase = true) -> buildPath("/storage/emulated/0", relative) + volume.equals("home", ignoreCase = true) -> buildPath("/storage/emulated/0/Documents", relative) + volume.isNotEmpty() -> buildPath("/storage/$volume", relative) + else -> null + } + } + + private fun buildPath(base: String, relative: String): String { + if (relative.isBlank()) { + return base + } + return "$base/${relative.trimStart('/')}" + } + + private fun normalizeFilesystemPath(path: String): String { + val absolute = File(path).absolutePath.replace('\\', '/') + return if (absolute.length > 1 && absolute.endsWith("/")) { + absolute.dropLast(1) + } else { + absolute + } + } + + @Suppress("DEPRECATION") + private fun getExtraStream(intent: Intent): Uri? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri + } + } +} diff --git a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt index 13c085a5f3..025d7240a3 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/LauncherActivity.kt @@ -4,9 +4,15 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Bundle +import android.util.Log +import android.widget.Toast import java.io.File class LauncherActivity : Activity() { + companion object { + private const val TAG = "LauncherActivity" + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -27,6 +33,7 @@ class LauncherActivity : Activity() { val hddUri = hddUriStr?.let(Uri::parse) val dvdUri = dvdUriStr?.let(Uri::parse) val gamesFolderUri = gamesFolderUriStr?.let(Uri::parse) + val frontendLaunch = FrontendLaunchHelper.resolve(this, intent, gamesFolderUri) val hasMcpx = hasLocalFile(mcpxPath) || (mcpxUri != null && hasPersistedReadPermission(mcpxUri)) val hasFlash = hasLocalFile(flashPath) || (flashUri != null && hasPersistedReadPermission(flashUri)) @@ -82,6 +89,40 @@ class LauncherActivity : Activity() { editor.apply() } + if (frontendLaunch != null) { + if (frontendLaunch.dvdUri != null) { + FrontendLaunchHelper.persistReadPermission(this, intent, frontendLaunch.dvdUri) + } + prefs.edit() + .putBoolean("skip_game_picker", false) + .apply { + when { + frontendLaunch.dvdUri != null -> { + putString("dvdUri", frontendLaunch.dvdUri.toString()) + remove("dvdPath") + } + frontendLaunch.dvdPath != null -> { + putString("dvdPath", frontendLaunch.dvdPath) + remove("dvdUri") + } + } + } + .apply() + + if (hasMcpx && hasFlash && hasHdd) { + Log.i(TAG, "Frontend launch resolved via ${frontendLaunch.source}") + startActivity(Intent(this, MainActivity::class.java)) + finish() + return + } + + Log.i(TAG, "Frontend launch queued, but core setup is incomplete") + Toast.makeText(this, R.string.frontend_launch_setup_required, Toast.LENGTH_SHORT).show() + } else if (hasExternalLaunchPayload(intent)) { + Log.w(TAG, "Frontend intent received but no accessible game target was resolved") + Toast.makeText(this, R.string.frontend_launch_unresolved, Toast.LENGTH_LONG).show() + } + val needsSetup = !setupComplete || !hasMcpx || !hasFlash || !hasHdd || !hasGamesFolder val next = if (needsSetup) SetupWizardActivity::class.java else GameLibraryActivity::class.java @@ -98,4 +139,28 @@ class LauncherActivity : Activity() { private fun hasLocalFile(path: String?): Boolean { return path != null && File(path).isFile } + + private fun hasExternalLaunchPayload(intent: Intent?): Boolean { + if (intent == null) { + return false + } + if (intent.data != null || intent.clipData != null) { + return true + } + return sequenceOf( + Intent.EXTRA_STREAM, + "rom", + "ROM", + "path", + "PATH", + "file", + "FILE", + "filename", + "FILENAME", + "romPath", + "ROM_PATH", + "uri", + "URI", + ).any { key -> intent.hasExtra(key) } + } } diff --git a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt index 72b44dacf2..0f48965e12 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/MainActivity.kt @@ -172,6 +172,11 @@ class MainActivity : SDLActivity(), InputManager.InputDeviceListener { scheduleStartupSnapshotLoadIfRequested() } + override fun onPause() { + onScreenController?.resetAllInputs() + super.onPause() + } + private fun scheduleStartupSnapshotLoadIfRequested() { val slot = startupSnapshotSlot ?: return if (startupSnapshotLoadScheduled) { diff --git a/android/app/src/main/java/com/izzy2lost/x1box/OnScreenController.kt b/android/app/src/main/java/com/izzy2lost/x1box/OnScreenController.kt index e18e74012b..fc238c740e 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/OnScreenController.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/OnScreenController.kt @@ -373,20 +373,41 @@ class OnScreenController @JvmOverloads constructor( // Check buttons buttons.forEach { (button, state) -> if (state.activePointerId == -1 && isPointInCircle(x, y, state.center, state.radius)) { - state.isPressed = true - state.activePointerId = pointerId - controllerListener?.onButtonPressed(button) + pressButton(button, state, pointerId) return } } } private fun handleTouchMove(x: Float, y: Float, pointerId: Int) { + if (menuButtonPointerId == pointerId) { + menuButtonPressed = isPointInCircle(x, y, menuButtonCenter, menuButtonRadius) + return + } + sticks.forEach { (stick, state) -> if (state.activePointerId == pointerId) { updateStickPosition(stick, state, x, y) + return } } + + val activeButton = buttons.entries.firstOrNull { it.value.activePointerId == pointerId } + if (activeButton != null) { + val (button, state) = activeButton + if (isPointInCircle(x, y, state.center, state.radius)) { + return + } + releaseButton(button, state) + } + + val hoveredButton = buttons.entries.firstOrNull { (_, state) -> + state.activePointerId == -1 && isPointInCircle(x, y, state.center, state.radius) + } + if (hoveredButton != null) { + val (button, state) = hoveredButton + pressButton(button, state, pointerId) + } } private fun handleTouchUp(pointerId: Int) { @@ -415,9 +436,7 @@ class OnScreenController @JvmOverloads constructor( // Release buttons buttons.forEach { (button, state) -> if (state.activePointerId == pointerId) { - state.isPressed = false - state.activePointerId = -1 - controllerListener?.onButtonReleased(button) + releaseButton(button, state) } } } @@ -442,13 +461,40 @@ class OnScreenController @JvmOverloads constructor( buttons.forEach { (button, state) -> if (state.isPressed) { - state.isPressed = false - state.activePointerId = -1 - controllerListener?.onButtonReleased(button) + releaseButton(button, state) } } } + fun resetAllInputs() { + handleCancel() + invalidate() + } + + override fun onDetachedFromWindow() { + resetAllInputs() + super.onDetachedFromWindow() + } + + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + if (changedView === this && visibility != View.VISIBLE) { + resetAllInputs() + } + } + + private fun pressButton(button: Button, state: ButtonState, pointerId: Int) { + state.isPressed = true + state.activePointerId = pointerId + controllerListener?.onButtonPressed(button) + } + + private fun releaseButton(button: Button, state: ButtonState) { + state.isPressed = false + state.activePointerId = -1 + controllerListener?.onButtonReleased(button) + } + private fun updateStickPosition(stick: Stick, state: StickState, x: Float, y: Float) { val dx = x - state.center.x val dy = y - state.center.y diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e596f3ca10..2518fc1de9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -87,6 +87,8 @@ Linear Nearest VSync + Finish core setup before launching from a frontend. + Could not access that game from the frontend intent. Pick the same folder in x1box first, or use a content URI. Custom Vulkan Driver System Default Browse…