Added frontend support

This commit is contained in:
izzy2lost
2026-03-08 21:34:00 -04:00
parent 322083cd6d
commit f333829803
7 changed files with 388 additions and 15 deletions
+13
View File
@@ -18,6 +18,19 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:scheme="file" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<activity
@@ -24,10 +24,9 @@ class ControllerInputBridge : OnScreenController.ControllerListener {
override fun onButtonPressed(button: OnScreenController.Button) {
try {
when (button) {
OnScreenController.Button.LEFT_TRIGGER ->
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
@@ -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<Pair<String, Any>> {
val candidates = ArrayList<Pair<String, Any>>()
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
}
}
}
@@ -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) }
}
}
@@ -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) {
@@ -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
@@ -87,6 +87,8 @@
<string name="settings_filtering_linear">Linear</string>
<string name="settings_filtering_nearest">Nearest</string>
<string name="settings_vsync">VSync</string>
<string name="frontend_launch_setup_required">Finish core setup before launching from a frontend.</string>
<string name="frontend_launch_unresolved">Could not access that game from the frontend intent. Pick the same folder in x1box first, or use a content URI.</string>
<string name="settings_section_vulkan_driver">Custom Vulkan Driver</string>
<string name="settings_vulkan_driver_none">System Default</string>
<string name="settings_vulkan_driver_browse">Browse…</string>