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…