mirror of
https://github.com/izzy2lost/xemu.git
synced 2026-07-06 00:20:22 -07:00
Added frontend support
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user